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 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.
|
||||
|
||||
+3
-3
@@ -968,19 +968,19 @@ async fn delete(app: State<App>, 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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -39,6 +39,15 @@
|
||||
</form>
|
||||
</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">
|
||||
<h2 id="load-heading">click-to-load</h2>
|
||||
<ul data-hemx-slot="loaded_row">
|
||||
@@ -53,6 +62,57 @@
|
||||
<p data-hemx-slot="load_status">{+ self.load_status +}</p>
|
||||
</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">
|
||||
<h2 id="search-heading">active-search</h2>
|
||||
<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-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"
|
||||
| "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",
|
||||
));
|
||||
}
|
||||
"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) =>
|
||||
{
|
||||
return Err(invalid_hemx_value(
|
||||
@@ -2743,12 +2747,24 @@ fn main() {{
|
||||
"data-hemx-debounce",
|
||||
"250ms",
|
||||
),
|
||||
(
|
||||
"delay",
|
||||
r#"<button data-hemx-handle="save" data-hemx-delay="soon">Save</button>"#,
|
||||
"data-hemx-delay",
|
||||
"250ms",
|
||||
),
|
||||
(
|
||||
"every",
|
||||
r#"<button data-hemx-handle="save" data-hemx-every="1sec">Save</button>"#,
|
||||
"data-hemx-every",
|
||||
"1s",
|
||||
),
|
||||
(
|
||||
"interval",
|
||||
r#"<button data-hemx-handle="save" data-hemx-interval="often">Save</button>"#,
|
||||
"data-hemx-interval",
|
||||
"1s",
|
||||
),
|
||||
(
|
||||
"event",
|
||||
r#"<button data-hemx-handle="save" data-hemx-on="keydown">Save</button>"#,
|
||||
|
||||
+34
-3
@@ -9,6 +9,8 @@
|
||||
const queues = new WeakMap();
|
||||
const timers = new WeakMap();
|
||||
const everyTimers = new WeakMap();
|
||||
const revealObservers = new WeakMap();
|
||||
const revealed = new WeakSet();
|
||||
const pendingClassStates = new WeakMap();
|
||||
const indicatorStates = new WeakMap();
|
||||
const busyStates = new WeakMap();
|
||||
@@ -654,14 +656,18 @@
|
||||
});
|
||||
});
|
||||
bindPolling(root);
|
||||
bindRevealed(root);
|
||||
}
|
||||
|
||||
function schedule(el, eventName, source = el) {
|
||||
const debounce = duration(el.getAttribute("data-hemx-debounce"));
|
||||
const delay = duration(el.getAttribute("data-hemx-delay"));
|
||||
const throttle = duration(el.getAttribute("data-hemx-throttle"));
|
||||
if (debounce) {
|
||||
clearTimeout(timers.get(el));
|
||||
timers.set(el, setTimeout(() => send(el, eventName, source), debounce));
|
||||
} else if (delay) {
|
||||
setTimeout(() => send(el, eventName, source), delay);
|
||||
} else if (throttle) {
|
||||
if (timers.get(el)) return;
|
||||
send(el, eventName, source).finally(() => setTimeout(() => timers.delete(el), throttle));
|
||||
@@ -672,10 +678,11 @@
|
||||
|
||||
function bindPolling(root) {
|
||||
forEachElement(root, (el) => {
|
||||
if (!el.hasAttribute("data-hemx-every") || everyTimers.has(el)) return;
|
||||
const ms = duration(el.getAttribute("data-hemx-every"));
|
||||
if ((!el.hasAttribute("data-hemx-every") && !el.hasAttribute("data-hemx-interval")) || everyTimers.has(el)) return;
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
const url = root.getAttribute("data-hemx-sse");
|
||||
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;
|
||||
|
||||
assert!(source.contains("const everyTimers = new WeakMap()"));
|
||||
assert!(source.contains("forEachElement(root, (el) =>"));
|
||||
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("data-hemx-interval"));
|
||||
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("clearInterval(everyTimers.get(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]
|
||||
fn runtime_toggles_pending_conventions_around_requests() {
|
||||
// req: convention/007 req: convention/008
|
||||
|
||||
Reference in New Issue
Block a user