feat(runtime): add URL-state GET navigation

This commit is contained in:
slhx agent
2026-06-29 18:21:00 +02:00
parent 9dd8e4a944
commit d0e7022c86
10 changed files with 185 additions and 97 deletions
+8 -1
View File
@@ -13,6 +13,13 @@ cargo run -p hemx-html-examples
Open <http://127.0.0.1:3029>.
The active-search example is URL state rather than an interaction handle: its
GET form serializes the visible `q` control into the page URL, live input uses
`data-hemx-history="replace"`, and the explicit submit button uses
`data-hemx-history="push"`. Reload, bookmark, and browser back/forward therefore
ask the same server route to re-render the filtered gallery instead of restoring
client-owned search state. req: page_swap/009 req: page_swap/010
## Pattern matrix
Names match the htmx example URL slug exactly, e.g. `modal-custom` from
@@ -38,7 +45,7 @@ Status legend:
| `lazy-load` | implemented | `data-hemx-revealed` dispatches a generated form once when visible; the server swaps a generated lazy panel. | `gallery.heml`, `gallery_handlers::lazy_load`, `LazyPanel` |
| `inline-validation` | implemented | 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` |
| `infinite-scroll` | implemented | A revealed sentinel form posts to the same server-owned loading model and replaces generated keyed rows. | `gallery_handlers::infinite_scroll`, `data-hemx-revealed`, `infinite_row` |
| `active-search` | implemented | The search form posts a query; the server derives result rows and reconciles generated keyed partials by removing filtered-out keys, replacing retained keys, and appending newly visible keys. | `gallery_handlers::search`, `SearchResult` |
| `active-search` | implemented | The search form uses GET URL state; the server derives result rows and reconciles generated keyed partials by removing filtered-out keys, replacing retained keys, and appending newly visible keys. | `gallery_handlers::search`, `SearchResult` |
| `progress-bar` | implemented | The Tick progress button advances server-owned progress and replaces a generated progress partial with visible percentage text. | `gallery_handlers::tick_progress`, `ProgressMeter` |
| `value-select` | implemented | The first select posts a generated form; the server derives and replaces generated option rows for the second select. | `gallery_handlers::choose_category`, `ValueOption` |
| `animations` | integration-owned | CSS transitions are presentation policy around generated replacements; hemx should only preserve stable DOM boundaries. | Use keyed partials and app CSS; no core animation framework. |
+77 -86
View File
@@ -1,5 +1,6 @@
use axum::body::Body;
use axum::extract::State;
use axum::http::Uri;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::Router;
@@ -27,7 +28,6 @@ struct GalleryState {
category: Mutex<String>,
value: Mutex<String>,
reset_status: Mutex<String>,
query: Mutex<String>,
}
#[derive(Clone)]
@@ -125,11 +125,6 @@ struct ResetMessage {
message: String,
}
#[hemx::form("search")]
struct SearchInput {
query: String,
}
#[derive(Hemplate)]
struct AppShell {
runtime_src: &'static str,
@@ -287,14 +282,18 @@ impl GalleryState {
category: Mutex::new("letters".into()),
value: Mutex::new("alpha".into()),
reset_status: Mutex::new("No message sent yet".into()),
query: Mutex::new(String::new()),
}
}
}
async fn home(State(state): State<Arc<GalleryState>>, request: PageRequest) -> impl IntoResponse {
async fn home(
State(state): State<Arc<GalleryState>>,
uri: Uri,
request: PageRequest,
) -> impl IntoResponse {
let query = query_param(uri.query(), "q");
request
.page_html(ui::page(&gallery_view(&state)), shell)
.page_html(ui::page(&gallery_view(&state, &query)), shell)
.title("hemx HTML examples")
.fingerprint(ui::BUILD_FINGERPRINT)
}
@@ -329,7 +328,7 @@ fn shell(body: Html) -> Html {
})
}
fn gallery_view(state: &GalleryState) -> Gallery {
fn gallery_view(state: &GalleryState, query: &str) -> Gallery {
let contacts = state
.contacts
.lock()
@@ -355,7 +354,7 @@ fn gallery_view(state: &GalleryState) -> Gallery {
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 query = query.trim().to_owned();
let search_results = search_results_for(&query);
Gallery {
contacts,
@@ -458,6 +457,43 @@ fn search_results_for(query: &str) -> Vec<SearchResult> {
.collect()
}
fn query_param(query: Option<&str>, name: &str) -> String {
query
.unwrap_or("")
.split('&')
.filter_map(|pair| pair.split_once('='))
.find_map(|(key, value)| (key == name).then(|| form_decode(value)))
.unwrap_or_default()
}
fn form_decode(value: &str) -> String {
let mut bytes = Vec::with_capacity(value.len());
let mut input = value.as_bytes().iter().copied();
while let Some(byte) = input.next() {
match byte {
b'+' => bytes.push(b' '),
b'%' => {
let high = input.next().and_then(hex_value);
let low = input.next().and_then(hex_value);
if let (Some(high), Some(low)) = (high, low) {
bytes.push((high << 4) | low);
}
}
byte => bytes.push(byte),
}
}
String::from_utf8_lossy(&bytes).into_owned()
}
fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
#[hemx::component("gallery")]
mod gallery_handlers {
use super::*;
@@ -586,50 +622,6 @@ mod gallery_handlers {
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();
let previous_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 current_results = search_results_for(&query);
let previous_keys = previous_results
.iter()
.map(|result| result.id.to_string())
.collect::<std::collections::BTreeSet<_>>();
let current_keys = current_results
.iter()
.map(|result| result.id.to_string())
.collect::<std::collections::BTreeSet<_>>();
// Keep filtered keyed collections stable: remove filtered-out rows, replace retained
// rows, and append newly visible rows instead of clearing the whole list. req: list/006
let mut effects = previous_keys
.difference(&current_keys)
.cloned()
.map(|key| gallery::search_result.remove(key))
.collect::<Vec<_>>();
effects.extend(current_results.into_iter().map(|result| {
if previous_keys.contains(&result.id.to_string()) {
gallery::search_result.replace(result)
} else {
gallery::search_result.append(result)
}
}));
effects.push(gallery::search_status.set(if query.is_empty() {
"Showing all results".into()
} else {
format!("Results for {query}")
}));
effects
}
}
#[hemx::component("contact_card")]
@@ -731,18 +723,45 @@ mod tests {
#[test]
fn inline_validation_form_uses_input_event_for_revalidation() {
let state = Arc::new(GalleryState::seeded());
let html = gallery_view(&state).render().expect("render gallery");
let html = gallery_view(&state, "").render().expect("render gallery");
assert!(
html.contains("data-hemx-on=\"input\""),
"inline validation form must listen on input events"
);
}
#[test]
fn active_search_is_reconstructed_from_url_query() {
// req: page_swap/009 req: page_swap/010
let state = Arc::new(GalleryState::seeded());
let html = gallery_view(&state, &query_param(Some("q=ga"), "q"))
.render()
.expect("render gallery");
assert!(html.contains("name=\"q\" value=\"ga\""));
assert!(html.contains("Results for ga"));
assert!(html.contains("Gamma"));
assert!(!html.contains("Alpha"));
}
#[test]
fn active_search_form_marks_live_replace_and_submit_push() {
// req: page_swap/009
let state = Arc::new(GalleryState::seeded());
let html = gallery_view(&state, "").render().expect("render gallery");
assert!(html.contains("method=\"get\""));
assert!(html.contains("data-hemx-history=\"replace\""));
assert!(html.contains("data-hemx-on=\"input\""));
assert!(html.contains("data-hemx-history=\"push\""));
assert!(!html.contains("data-hemx-handle=\"search\""));
assert!(!html.contains("data-hemx-form=\"search\""));
assert!(!html.contains("name=\"__h\""));
}
#[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");
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"
@@ -792,7 +811,7 @@ mod tests {
#[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");
let html = gallery_view(&state, "").render().expect("render gallery");
assert!(html.contains("data-hemx-root=\"gallery\""));
for slug in [
"click-to-edit",
@@ -952,34 +971,6 @@ mod tests {
);
assert!(load.payload_contains("Loaded row 6"));
let search = inspect_batch(
InteractionRequest::from(form(gallery::search, &[("query", "ga")]))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(search.removes_key(gallery::search_result, "1"));
assert!(search.removes_key(gallery::search_result, "2"));
assert!(search.removes_key(gallery::search_result, "4"));
assert!(search.replaces_keyed_html_containing(gallery::search_result, "3", "Gamma"));
let broader_search = inspect_batch(
InteractionRequest::from(form(gallery::search, &[("query", "a")]))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(broader_search.inserts_html_containing(gallery::search_result, "1", "Alpha"));
assert!(broader_search.inserts_html_containing(gallery::search_result, "2", "Beta"));
assert!(broader_search.replaces_keyed_html_containing(
gallery::search_result,
"3",
"Gamma"
));
assert!(broader_search.inserts_html_containing(gallery::search_result, "4", "Delta"));
let row_save = inspect_batch(
InteractionRequest::from(form(
editable_row::save_row,
@@ -116,9 +116,9 @@
<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 method="get" action="/" data-hemx-history="replace" data-hemx-on="input" data-hemx-debounce="150ms">
<label>Search <input name="q" +value="self.query"></label>
<button type="submit" data-hemx-history="push">Search</button>
</form>
<p data-hemx-slot="search_status">{+ self.search_status +}</p>
<ul data-hemx-slot="search_result">