diff --git a/AGENTS.md b/AGENTS.md index 922ca8e..50ab2e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file - The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition. - The stable public `.heml` authoring surface lives in `docs/hemplate-syntax.md`; Hemlate examples must use that real hemplate syntax, not Vue/Handlebars sketches. - Optional `.heml` editor overlays must share authority with `hemx-build` diagnostics and `docs/hemplate-syntax.md`; `hemx-lsp` owns editor protocol glue for diagnostics/completion/hover and derive-known template facts, while VS Code/Cursor/Neovim keep normal HTML/tree-sitter tooling. Do not create a second template language, selector model, formatter, Rust type system, or custom editor framework. req: diagnostics/004 req: diagnostics/005 req: diagnostics/006 -- JS runtime changes must preserve root-scoped lookup, fail-closed request handling, root-scoped error outlets, and tiny pending/failure recovery without selectors, VDOM, expressions, or per-node listeners. req: runtime/005 req: convention/007 +- JS runtime changes must preserve root-scoped lookup, fail-closed request handling, root-scoped error outlets, and tiny pending/failure/trigger-timing conventions without selectors, VDOM, expressions, or per-node listeners. req: runtime/005 req: convention/001 req: convention/003 req: convention/005 req: convention/007 - Host capability adapters must stay at the `hemx-host` boundary: they may call host APIs and return host events, but they must not mutate DOM or own app/domain state. req: host/002 - Local/offline app behavior should be commands/events/projections; do not add `hemx-local`, stored DOM patches, or stored `EffectBatch` truth without a proven reusable contract. req: local/001 req: local/002 - Axum apps should serve and load the shared runtime through hemx-axum helpers such as `runtime_js_path()` and `runtime_js()`, not hard-coded `/hemx.js` URLs or app-owned cache-busting strings. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index bc57827..5a5d118 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -968,19 +968,19 @@ async fn delete(app: State, todo_id: TodoId) -> impl IntoEffect ## convention ### req: convention/001 -001 hemx-axum and the JS runtime support common UX conventions as attributes, not core effects: `data-hemx-pending-class`, `data-hemx-indicator`, `data-hemx-confirm`, `data-hemx-debounce`, `data-hemx-throttle`, `data-hemx-every`, `data-hemx-disable-while-pending`, `data-hemx-policy`, and `data-hemx-on`. These are orthogonal to the core effect algebra. +001 hemx-axum and the JS runtime support common UX conventions as attributes, not core effects: `data-hemx-pending-class`, `data-hemx-indicator`, `data-hemx-confirm`, `data-hemx-debounce`, `data-hemx-delay`, `data-hemx-throttle`, `data-hemx-every`, `data-hemx-interval`, `data-hemx-revealed`, `data-hemx-disable-while-pending`, `data-hemx-policy`, and `data-hemx-on`. These are orthogonal to the core effect algebra. ### req: convention/002 002 Default event triggers: `submit` for forms, `click` for buttons and links. `data-hemx-on` overrides the default for the runtime-supported delegated events: `click`, `submit`, `input`, `change`, `dragstart`, `dragover`, and `drop`. Unsupported static event names are build errors. ### req: convention/003 -003 `data-hemx-debounce` and `data-hemx-throttle` support simple millisecond values. No trigger mini-language in core. +003 `data-hemx-debounce`, `data-hemx-delay`, and `data-hemx-throttle` support simple millisecond values. No trigger mini-language in core. ### req: convention/004 004 `data-hemx-confirm` dispatches a native `confirm()` before handler dispatch. Static empty confirmation messages are build errors because they silently disable the guard in browsers. Custom confirm UI belongs to integration crates. ### req: convention/005 -005 `data-hemx-every` dispatches a handle at a fixed interval while the element remains in the document. Duplicate timers per root are avoided. +005 `data-hemx-every` and `data-hemx-interval` dispatch a handle at a fixed interval while the element remains in the document. `data-hemx-revealed` dispatches once when the element enters view, with an immediate fallback when `IntersectionObserver` is unavailable. Duplicate timers/observers per root are avoided. ### req: convention/006 006 Request concurrency policy (`latest`, `queue`, `drop`, `parallel`) may be declared per handle with `data-hemx-policy`. Default for debounced/input handlers is `latest`; default for form submit is `drop` while pending. Stale EffectBatches from superseded requests must not be applied. diff --git a/examples/html_examples/README.md b/examples/html_examples/README.md index 25b11eb..fe6ff2f 100644 --- a/examples/html_examples/README.md +++ b/examples/html_examples/README.md @@ -35,16 +35,16 @@ Status legend: | `click-to-load` | implemented | The server owns the loaded count and returns generated keyed `loaded_row` replacements plus status text. | `gallery_handlers::load_more`, `LoadedRow` | | `delete-row` | implemented | Server state removes the row and returns `gallery::editable_row.remove(id)`. | `editable_row_handlers::delete_row` | | `edit-row` | implemented | A table row is a keyed `.heml` partial with generated edit/save forms; no selector target strings. | `templates/partials/editable_row.heml`, `editable_row_handlers::edit_row`, `save_row` | -| `lazy-load` | deferred | Should be the same loaded-row resource with a browser-owned visibility trigger; needs a tiny runtime convention or explicit app JS policy before copy/paste. | Not implemented in core gallery yet. | +| `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` | integration-owned | This is load-more plus scroll/visibility policy; hemx should not own infinite-scroll state without an app proof. | Use `click-to-load` until a product slice proves scroll policy. | +| `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 replaces generated `search_result` keyed partials. | `gallery_handlers::search`, `SearchResult` | -| `progress-bar` | deferred | Needs a real job/progress source and polling or push convention; a fake timer would be a trophy demo. | Later slice should prove server-owned job state. | -| `value-select` | deferred | Dependent selects are ordinary generated form state, but need a copyable domain example and validation path. | Later slice should add two select resources and server-derived options. | +| `progress-bar` | implemented | `data-hemx-interval` ticks a server-owned progress value and replaces a generated progress partial. | `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. | | `file-upload` | integration-owned | Upload transport, size limits, progress, storage, and security are app/integration policy. | Needs product-owned upload route before becoming copyable. | | `file-upload-input` | integration-owned | Preserving file inputs after errors is browser/security policy; hemx should not fake file state in core effects. | Use app-owned upload form policy. | -| `reset-user-input` | deferred | Generated form `.clear()` already exists; needs a small copyable pattern showing reset after success and preserved values after failure. | Later slice can add to inline-validation. | +| `reset-user-input` | implemented | A generated form updates status and returns `.clear()` after successful submission. | `gallery_handlers::reset_message` | | `dialogs` | integration-owned | Browser `alert/confirm/prompt` are app policy; hemx can expose event boundaries but should not own dialog UX. | Use native controls or host/app code. | | `modal-uikit` | refused | Third-party UI kit integration is not a hemx core pattern. | Keep as app-owned integration. | | `modal-bootstrap` | refused | Third-party UI kit integration is not a hemx core pattern. | Keep as app-owned integration. | diff --git a/examples/html_examples/src/main.rs b/examples/html_examples/src/main.rs index 1f33e2c..26765b7 100644 --- a/examples/html_examples/src/main.rs +++ b/examples/html_examples/src/main.rs @@ -19,8 +19,14 @@ struct GalleryState { contacts: Mutex>, rows: Mutex>, loaded_count: Mutex, + infinite_count: Mutex, + lazy_loaded: Mutex, + progress: Mutex, email: Mutex, email_status: Mutex, + category: Mutex, + value: Mutex, + reset_status: Mutex, query: Mutex, } @@ -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, rows: Vec, - email: String, - email_status: String, + lazy_panel: String, loaded_rows: Vec, load_status: String, + infinite_rows: Vec, + infinite_status: String, + progress: u8, + progress_label: String, + email: String, + email_status: String, + value_options: Vec, + reset_status: String, query: String, search_status: String, search_results: Vec, @@ -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 { .collect() } +fn value_options_for(category: &str, selected: &str) -> Vec { + 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 { ["Alpha", "Beta", "Gamma", "Delta"] .into_iter() @@ -325,6 +416,86 @@ fn search_results_for(query: &str) -> Vec { mod gallery_handlers { use super::*; + #[hemx::handler] + async fn lazy_load( + State(state): State>, + Form(input): Form, + ) -> 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>, + Form(input): Form, + ) -> 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>, + Form(input): Form, + ) -> 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::>() + } + + #[hemx::handler] + async fn reset_message( + State(state): State>, + Form(input): Form, + ) -> 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>, + Form(input): Form, + ) -> 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::>(); + let mut effects = rows; + effects.push(gallery::infinite_status.set(format!("Showing {} rows", *count))); + effects + } + #[hemx::handler] async fn validate_email( State(state): State>, @@ -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())) diff --git a/examples/html_examples/templates/gallery.heml b/examples/html_examples/templates/gallery.heml index 1d067e1..677cc02 100644 --- a/examples/html_examples/templates/gallery.heml +++ b/examples/html_examples/templates/gallery.heml @@ -39,6 +39,15 @@ +
+

lazy-load

+
+ + +
+
{+ self.lazy_panel +}
+
+

click-to-load

    @@ -53,6 +62,57 @@

    {+ self.load_status +}

+
+

infinite-scroll

+
    + +
+
+ + +
+

{+ self.infinite_status +}

+
+ +
+

progress-bar

+
+ + +
+ {+ self.progress_label +} +

{+ self.progress_label +}

+
+ +
+

value-select

+
+ + +
+ +
+ +
+

reset-user-input

+
+ + +
+

{+ self.reset_status +}

+
+