feat(runtime): add URL-state GET navigation
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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(¤t_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">
|
||||
|
||||
Reference in New Issue
Block a user