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:
@@ -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 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.
|
- 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
|
- 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
|
- 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
|
- 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.
|
- 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.
|
||||||
|
|||||||
+3
-3
@@ -968,19 +968,19 @@ async fn delete(app: State<App>, todo_id: TodoId) -> impl IntoEffect
|
|||||||
## convention
|
## convention
|
||||||
|
|
||||||
### req: convention/001
|
### 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
|
### 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.
|
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
|
### 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
|
### 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.
|
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
|
### 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
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -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` |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `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. |
|
| `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` | 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. |
|
| `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. |
|
| `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` | 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. |
|
| `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. |
|
| `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-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. |
|
| `modal-bootstrap` | refused | Third-party UI kit integration is not a hemx core pattern. | Keep as app-owned integration. |
|
||||||
|
|||||||
@@ -19,8 +19,14 @@ struct GalleryState {
|
|||||||
contacts: Mutex<Vec<ContactRecord>>,
|
contacts: Mutex<Vec<ContactRecord>>,
|
||||||
rows: Mutex<Vec<RowRecord>>,
|
rows: Mutex<Vec<RowRecord>>,
|
||||||
loaded_count: Mutex<usize>,
|
loaded_count: Mutex<usize>,
|
||||||
|
infinite_count: Mutex<usize>,
|
||||||
|
lazy_loaded: Mutex<bool>,
|
||||||
|
progress: Mutex<u8>,
|
||||||
email: Mutex<String>,
|
email: Mutex<String>,
|
||||||
email_status: Mutex<String>,
|
email_status: Mutex<String>,
|
||||||
|
category: Mutex<String>,
|
||||||
|
value: Mutex<String>,
|
||||||
|
reset_status: Mutex<String>,
|
||||||
query: Mutex<String>,
|
query: Mutex<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +82,11 @@ struct LoadMore {
|
|||||||
request: String,
|
request: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[hemx::form("infinite_scroll")]
|
||||||
|
struct InfiniteScroll {
|
||||||
|
request: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[hemx::form("save_contact")]
|
#[hemx::form("save_contact")]
|
||||||
struct SaveContact {
|
struct SaveContact {
|
||||||
id: Id,
|
id: Id,
|
||||||
@@ -89,11 +100,31 @@ struct SaveRow {
|
|||||||
title: String,
|
title: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[hemx::form("lazy_load")]
|
||||||
|
struct LazyLoad {
|
||||||
|
request: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[hemx::form("tick_progress")]
|
||||||
|
struct TickProgress {
|
||||||
|
request: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[hemx::form("validate_email")]
|
#[hemx::form("validate_email")]
|
||||||
struct ValidateEmail {
|
struct ValidateEmail {
|
||||||
email: String,
|
email: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[hemx::form("choose_category")]
|
||||||
|
struct ChooseCategory {
|
||||||
|
category: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[hemx::form("reset_message")]
|
||||||
|
struct ResetMessage {
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[hemx::form("search")]
|
#[hemx::form("search")]
|
||||||
struct SearchInput {
|
struct SearchInput {
|
||||||
query: String,
|
query: String,
|
||||||
@@ -109,10 +140,17 @@ struct AppShell {
|
|||||||
struct Gallery {
|
struct Gallery {
|
||||||
contacts: Vec<ContactCard>,
|
contacts: Vec<ContactCard>,
|
||||||
rows: Vec<EditableRow>,
|
rows: Vec<EditableRow>,
|
||||||
email: String,
|
lazy_panel: String,
|
||||||
email_status: String,
|
|
||||||
loaded_rows: Vec<LoadedRow>,
|
loaded_rows: Vec<LoadedRow>,
|
||||||
load_status: String,
|
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,
|
query: String,
|
||||||
search_status: String,
|
search_status: String,
|
||||||
search_results: Vec<SearchResult>,
|
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)]
|
#[derive(Hemplate, Clone)]
|
||||||
struct SearchResult {
|
struct SearchResult {
|
||||||
id: Id,
|
id: Id,
|
||||||
@@ -205,8 +257,14 @@ impl GalleryState {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
loaded_count: Mutex::new(2),
|
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: Mutex::new(String::new()),
|
||||||
email_status: Mutex::new("Waiting for an email".into()),
|
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()),
|
query: Mutex::new(String::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,17 +318,34 @@ fn gallery_view(state: &GalleryState) -> Gallery {
|
|||||||
.map(editable_row_view)
|
.map(editable_row_view)
|
||||||
.collect();
|
.collect();
|
||||||
let loaded_count = *state.loaded_count.lock().unwrap();
|
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 = state.email.lock().unwrap().clone();
|
||||||
let email_status = state.email_status.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 query = state.query.lock().unwrap().clone();
|
||||||
let search_results = search_results_for(&query);
|
let search_results = search_results_for(&query);
|
||||||
Gallery {
|
Gallery {
|
||||||
contacts,
|
contacts,
|
||||||
rows,
|
rows,
|
||||||
email,
|
lazy_panel: if lazy_loaded {
|
||||||
email_status,
|
"Lazy content loaded".into()
|
||||||
|
} else {
|
||||||
|
"Waiting to be revealed".into()
|
||||||
|
},
|
||||||
loaded_rows: loaded_rows(loaded_count),
|
loaded_rows: loaded_rows(loaded_count),
|
||||||
load_status: format!("Showing {loaded_count} rows"),
|
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(),
|
query: query.clone(),
|
||||||
search_status: if query.is_empty() {
|
search_status: if query.is_empty() {
|
||||||
"Showing all results".into()
|
"Showing all results".into()
|
||||||
@@ -307,6 +382,22 @@ fn loaded_rows(count: usize) -> Vec<LoadedRow> {
|
|||||||
.collect()
|
.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> {
|
fn search_results_for(query: &str) -> Vec<SearchResult> {
|
||||||
["Alpha", "Beta", "Gamma", "Delta"]
|
["Alpha", "Beta", "Gamma", "Delta"]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -325,6 +416,86 @@ fn search_results_for(query: &str) -> Vec<SearchResult> {
|
|||||||
mod gallery_handlers {
|
mod gallery_handlers {
|
||||||
use super::*;
|
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]
|
#[hemx::handler]
|
||||||
async fn validate_email(
|
async fn validate_email(
|
||||||
State(state): State<Arc<GalleryState>>,
|
State(state): State<Arc<GalleryState>>,
|
||||||
@@ -531,8 +702,13 @@ mod tests {
|
|||||||
"click-to-edit",
|
"click-to-edit",
|
||||||
"edit-row",
|
"edit-row",
|
||||||
"delete-row",
|
"delete-row",
|
||||||
|
"lazy-load",
|
||||||
"inline-validation",
|
"inline-validation",
|
||||||
|
"infinite-scroll",
|
||||||
"click-to-load",
|
"click-to-load",
|
||||||
|
"progress-bar",
|
||||||
|
"value-select",
|
||||||
|
"reset-user-input",
|
||||||
"active-search",
|
"active-search",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
@@ -541,7 +717,11 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert!(html.contains("data-hemx-form=\"validate_email\""));
|
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=\"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\""));
|
assert!(html.contains("data-hemx-slot=\"search_result\""));
|
||||||
|
|
||||||
let edit = inspect_batch(
|
let edit = inspect_batch(
|
||||||
@@ -565,6 +745,16 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(save.updates_html_containing(gallery::contact_card, "ada@hemx.test"));
|
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(
|
let invalid = inspect_batch(
|
||||||
InteractionRequest::from(form(gallery::validate_email, &[("email", "bad")]))
|
InteractionRequest::from(form(gallery::validate_email, &[("email", "bad")]))
|
||||||
.dispatch_async(handlers(state.clone()))
|
.dispatch_async(handlers(state.clone()))
|
||||||
@@ -575,6 +765,48 @@ mod tests {
|
|||||||
assert!(invalid.payload_contains("Use a real email address"));
|
assert!(invalid.payload_contains("Use a real email address"));
|
||||||
assert!(invalid.updates_text(gallery::email_status));
|
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(
|
let load = inspect_batch(
|
||||||
InteractionRequest::from(form(gallery::load_more, &[("request", "more")]))
|
InteractionRequest::from(form(gallery::load_more, &[("request", "more")]))
|
||||||
.dispatch_async(handlers(state.clone()))
|
.dispatch_async(handlers(state.clone()))
|
||||||
|
|||||||
@@ -39,6 +39,15 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="lazy-load" data-htmx-example="lazy-load" aria-labelledby="lazy-heading">
|
||||||
|
<h2 id="lazy-heading">lazy-load</h2>
|
||||||
|
<form data-hemx-handle="lazy_load" data-hemx-form="lazy_load" data-hemx-revealed="true">
|
||||||
|
<input type="hidden" name="request" value="lazy">
|
||||||
|
<button type="submit">Load lazy content</button>
|
||||||
|
</form>
|
||||||
|
<div data-hemx-slot="lazy_panel">{+ self.lazy_panel +}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="click-to-load" data-htmx-example="click-to-load" aria-labelledby="load-heading">
|
<section id="click-to-load" data-htmx-example="click-to-load" aria-labelledby="load-heading">
|
||||||
<h2 id="load-heading">click-to-load</h2>
|
<h2 id="load-heading">click-to-load</h2>
|
||||||
<ul data-hemx-slot="loaded_row">
|
<ul data-hemx-slot="loaded_row">
|
||||||
@@ -53,6 +62,57 @@
|
|||||||
<p data-hemx-slot="load_status">{+ self.load_status +}</p>
|
<p data-hemx-slot="load_status">{+ self.load_status +}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="infinite-scroll" data-htmx-example="infinite-scroll" aria-labelledby="infinite-heading">
|
||||||
|
<h2 id="infinite-heading">infinite-scroll</h2>
|
||||||
|
<ul data-hemx-slot="infinite_row">
|
||||||
|
<template h-for="row in &self.infinite_rows" h-key="row.id">
|
||||||
|
{+ row +}
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
<form data-hemx-handle="infinite_scroll" data-hemx-form="infinite_scroll" data-hemx-revealed="true">
|
||||||
|
<input type="hidden" name="request" value="more">
|
||||||
|
<button type="submit">Reveal more rows</button>
|
||||||
|
</form>
|
||||||
|
<p data-hemx-slot="infinite_status">{+ self.infinite_status +}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="progress-bar" data-htmx-example="progress-bar" aria-labelledby="progress-heading">
|
||||||
|
<h2 id="progress-heading">progress-bar</h2>
|
||||||
|
<form data-hemx-handle="tick_progress" data-hemx-form="tick_progress" data-hemx-interval="500ms">
|
||||||
|
<input type="hidden" name="request" value="tick">
|
||||||
|
<button type="submit">Tick progress</button>
|
||||||
|
</form>
|
||||||
|
<progress max="100" +value="self.progress">{+ self.progress_label +}</progress>
|
||||||
|
<p data-hemx-slot="progress_meter">{+ self.progress_label +}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="value-select" data-htmx-example="value-select" aria-labelledby="value-heading">
|
||||||
|
<h2 id="value-heading">value-select</h2>
|
||||||
|
<form data-hemx-handle="choose_category" data-hemx-form="choose_category">
|
||||||
|
<label>Category
|
||||||
|
<select name="category">
|
||||||
|
<option value="letters">Letters</option>
|
||||||
|
<option value="numbers">Numbers</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Choose</button>
|
||||||
|
</form>
|
||||||
|
<select data-hemx-slot="value_option" name="value">
|
||||||
|
<template h-for="option in &self.value_options" h-key="option.id">
|
||||||
|
{+ option +}
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="reset-user-input" data-htmx-example="reset-user-input" aria-labelledby="reset-heading">
|
||||||
|
<h2 id="reset-heading">reset-user-input</h2>
|
||||||
|
<form data-hemx-handle="reset_message" data-hemx-form="reset_message">
|
||||||
|
<label>Message <input name="message"></label>
|
||||||
|
<button type="submit">Send and reset</button>
|
||||||
|
</form>
|
||||||
|
<p data-hemx-slot="reset_status">{+ self.reset_status +}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="active-search" data-htmx-example="active-search" aria-labelledby="search-heading">
|
<section id="active-search" data-htmx-example="active-search" aria-labelledby="search-heading">
|
||||||
<h2 id="search-heading">active-search</h2>
|
<h2 id="search-heading">active-search</h2>
|
||||||
<form data-hemx-handle="search" data-hemx-form="search">
|
<form data-hemx-handle="search" data-hemx-form="search">
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<option +data-key="self.id" +value="self.value" +selected="self.selected">{+ self.label +}</option>
|
||||||
+17
-1
@@ -1534,8 +1534,11 @@ fn known_hemx_attr(name: &str) -> bool {
|
|||||||
| "data-hemx-indicator"
|
| "data-hemx-indicator"
|
||||||
| "data-hemx-confirm"
|
| "data-hemx-confirm"
|
||||||
| "data-hemx-debounce"
|
| "data-hemx-debounce"
|
||||||
|
| "data-hemx-delay"
|
||||||
| "data-hemx-throttle"
|
| "data-hemx-throttle"
|
||||||
| "data-hemx-every"
|
| "data-hemx-every"
|
||||||
|
| "data-hemx-interval"
|
||||||
|
| "data-hemx-revealed"
|
||||||
| "data-hemx-disable-while-pending"
|
| "data-hemx-disable-while-pending"
|
||||||
| "data-hemx-policy"
|
| "data-hemx-policy"
|
||||||
| "data-hemx-nav"
|
| "data-hemx-nav"
|
||||||
@@ -1585,7 +1588,8 @@ fn reject_invalid_hemx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> i
|
|||||||
"expected a non-empty same-origin SSE URL",
|
"expected a non-empty same-origin SSE URL",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
"data-hemx-debounce" | "data-hemx-throttle" | "data-hemx-every"
|
"data-hemx-debounce" | "data-hemx-delay" | "data-hemx-throttle" | "data-hemx-every"
|
||||||
|
| "data-hemx-interval"
|
||||||
if !valid_duration(value) =>
|
if !valid_duration(value) =>
|
||||||
{
|
{
|
||||||
return Err(invalid_hemx_value(
|
return Err(invalid_hemx_value(
|
||||||
@@ -2743,12 +2747,24 @@ fn main() {{
|
|||||||
"data-hemx-debounce",
|
"data-hemx-debounce",
|
||||||
"250ms",
|
"250ms",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"delay",
|
||||||
|
r#"<button data-hemx-handle="save" data-hemx-delay="soon">Save</button>"#,
|
||||||
|
"data-hemx-delay",
|
||||||
|
"250ms",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"every",
|
"every",
|
||||||
r#"<button data-hemx-handle="save" data-hemx-every="1sec">Save</button>"#,
|
r#"<button data-hemx-handle="save" data-hemx-every="1sec">Save</button>"#,
|
||||||
"data-hemx-every",
|
"data-hemx-every",
|
||||||
"1s",
|
"1s",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"interval",
|
||||||
|
r#"<button data-hemx-handle="save" data-hemx-interval="often">Save</button>"#,
|
||||||
|
"data-hemx-interval",
|
||||||
|
"1s",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"event",
|
"event",
|
||||||
r#"<button data-hemx-handle="save" data-hemx-on="keydown">Save</button>"#,
|
r#"<button data-hemx-handle="save" data-hemx-on="keydown">Save</button>"#,
|
||||||
|
|||||||
+34
-3
@@ -9,6 +9,8 @@
|
|||||||
const queues = new WeakMap();
|
const queues = new WeakMap();
|
||||||
const timers = new WeakMap();
|
const timers = new WeakMap();
|
||||||
const everyTimers = new WeakMap();
|
const everyTimers = new WeakMap();
|
||||||
|
const revealObservers = new WeakMap();
|
||||||
|
const revealed = new WeakSet();
|
||||||
const pendingClassStates = new WeakMap();
|
const pendingClassStates = new WeakMap();
|
||||||
const indicatorStates = new WeakMap();
|
const indicatorStates = new WeakMap();
|
||||||
const busyStates = new WeakMap();
|
const busyStates = new WeakMap();
|
||||||
@@ -654,14 +656,18 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
bindPolling(root);
|
bindPolling(root);
|
||||||
|
bindRevealed(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
function schedule(el, eventName, source = el) {
|
function schedule(el, eventName, source = el) {
|
||||||
const debounce = duration(el.getAttribute("data-hemx-debounce"));
|
const debounce = duration(el.getAttribute("data-hemx-debounce"));
|
||||||
|
const delay = duration(el.getAttribute("data-hemx-delay"));
|
||||||
const throttle = duration(el.getAttribute("data-hemx-throttle"));
|
const throttle = duration(el.getAttribute("data-hemx-throttle"));
|
||||||
if (debounce) {
|
if (debounce) {
|
||||||
clearTimeout(timers.get(el));
|
clearTimeout(timers.get(el));
|
||||||
timers.set(el, setTimeout(() => send(el, eventName, source), debounce));
|
timers.set(el, setTimeout(() => send(el, eventName, source), debounce));
|
||||||
|
} else if (delay) {
|
||||||
|
setTimeout(() => send(el, eventName, source), delay);
|
||||||
} else if (throttle) {
|
} else if (throttle) {
|
||||||
if (timers.get(el)) return;
|
if (timers.get(el)) return;
|
||||||
send(el, eventName, source).finally(() => setTimeout(() => timers.delete(el), throttle));
|
send(el, eventName, source).finally(() => setTimeout(() => timers.delete(el), throttle));
|
||||||
@@ -672,10 +678,11 @@
|
|||||||
|
|
||||||
function bindPolling(root) {
|
function bindPolling(root) {
|
||||||
forEachElement(root, (el) => {
|
forEachElement(root, (el) => {
|
||||||
if (!el.hasAttribute("data-hemx-every") || everyTimers.has(el)) return;
|
if ((!el.hasAttribute("data-hemx-every") && !el.hasAttribute("data-hemx-interval")) || everyTimers.has(el)) return;
|
||||||
const ms = duration(el.getAttribute("data-hemx-every"));
|
const eventName = el.hasAttribute("data-hemx-interval") ? "interval" : "every";
|
||||||
|
const ms = duration(el.getAttribute("data-hemx-interval") || el.getAttribute("data-hemx-every"));
|
||||||
if (!ms) return;
|
if (!ms) return;
|
||||||
everyTimers.set(el, setInterval(() => document.contains(el) ? send(el, "every") : stopPolling(el), ms));
|
everyTimers.set(el, setInterval(() => document.contains(el) ? send(el, eventName) : stopPolling(el), ms));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,6 +691,30 @@
|
|||||||
everyTimers.delete(el);
|
everyTimers.delete(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindRevealed(root) {
|
||||||
|
let observer = revealObservers.get(root);
|
||||||
|
forEachElement(root, (el) => {
|
||||||
|
if (!el.hasAttribute("data-hemx-revealed") || revealed.has(el)) return;
|
||||||
|
if (typeof IntersectionObserver === "undefined") {
|
||||||
|
revealed.add(el);
|
||||||
|
schedule(el, "revealed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!observer) {
|
||||||
|
observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (!entry.isIntersecting || revealed.has(entry.target)) return;
|
||||||
|
revealed.add(entry.target);
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
schedule(entry.target, "revealed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
revealObservers.set(root, observer);
|
||||||
|
}
|
||||||
|
observer.observe(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function bindSse(root) {
|
function bindSse(root) {
|
||||||
const url = root.getAttribute("data-hemx-sse");
|
const url = root.getAttribute("data-hemx-sse");
|
||||||
if (!url || sseSources.has(root) || typeof EventSource === "undefined") return;
|
if (!url || sseSources.has(root) || typeof EventSource === "undefined") return;
|
||||||
|
|||||||
@@ -101,19 +101,32 @@ fn runtime_interval_dispatch_avoids_duplicate_timers() {
|
|||||||
let source = hemx_js::RUNTIME_JS;
|
let source = hemx_js::RUNTIME_JS;
|
||||||
|
|
||||||
assert!(source.contains("const everyTimers = new WeakMap()"));
|
assert!(source.contains("const everyTimers = new WeakMap()"));
|
||||||
assert!(source.contains("forEachElement(root, (el) =>"));
|
assert!(source.contains("data-hemx-interval"));
|
||||||
assert!(source.contains("!el.hasAttribute(\"data-hemx-every\") || everyTimers.has(el)"));
|
|
||||||
assert!(
|
|
||||||
source.contains("if (!el.hasAttribute(\"data-hemx-every\") || everyTimers.has(el)) return")
|
|
||||||
);
|
|
||||||
assert!(source.contains(
|
assert!(source.contains(
|
||||||
"setInterval(() => document.contains(el) ? send(el, \"every\") : stopPolling(el), ms)"
|
"eventName = el.hasAttribute(\"data-hemx-interval\") ? \"interval\" : \"every\""
|
||||||
|
));
|
||||||
|
assert!(source.contains(
|
||||||
|
"setInterval(() => document.contains(el) ? send(el, eventName) : stopPolling(el), ms)"
|
||||||
));
|
));
|
||||||
assert!(source.contains("function stopPolling(el)"));
|
assert!(source.contains("function stopPolling(el)"));
|
||||||
assert!(source.contains("clearInterval(everyTimers.get(el))"));
|
assert!(source.contains("clearInterval(everyTimers.get(el))"));
|
||||||
assert!(source.contains("everyTimers.delete(el)"));
|
assert!(source.contains("everyTimers.delete(el)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_supports_tiny_delay_and_revealed_scheduling() {
|
||||||
|
// req: convention/001 req: convention/003 req: convention/005
|
||||||
|
let source = hemx_js::RUNTIME_JS;
|
||||||
|
|
||||||
|
assert!(source.contains("data-hemx-delay"));
|
||||||
|
assert!(source.contains("setTimeout(() => send(el, eventName, source), delay)"));
|
||||||
|
assert!(source.contains("const revealObservers = new WeakMap()"));
|
||||||
|
assert!(source.contains("const revealed = new WeakSet()"));
|
||||||
|
assert!(source.contains("data-hemx-revealed"));
|
||||||
|
assert!(source.contains("typeof IntersectionObserver === \"undefined\""));
|
||||||
|
assert!(source.contains("schedule(entry.target, \"revealed\")"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_toggles_pending_conventions_around_requests() {
|
fn runtime_toggles_pending_conventions_around_requests() {
|
||||||
// req: convention/007 req: convention/008
|
// req: convention/007 req: convention/008
|
||||||
|
|||||||
Reference in New Issue
Block a user