Current section
Files
Jump to
Current section
Files
src/lightspeed/form_compat.gleam
//// LiveView-style form change/submit compatibility semantics.
import gleam/list
import gleam/option.{type Option, None, Some}
import lightspeed/form
/// Client-side rate limit mode for one input.
pub type ChangeMode {
Immediate
Debounce(wait_ms: Int)
DebounceBlur
Throttle(window_ms: Int)
}
/// Input registration used by the form compatibility runtime.
pub type InputConfig {
InputConfig(name: String, mode: ChangeMode)
}
/// Deterministic dispatch decision.
pub type Dispatch {
DispatchChange(input: String, payload: form.FormData)
DispatchSubmit(payload: form.FormData)
Queued(input: String)
Suppressed(input: String, reason: String)
NoDispatch
}
type InputState {
InputState(
name: String,
mode: ChangeMode,
last_dispatch_at_ms: Option(Int),
pending_payload: Option(form.FormData),
pending_since_ms: Option(Int),
)
}
/// Form compatibility runtime with one state record per registered input.
pub opaque type Runtime {
Runtime(inputs: List(InputState))
}
/// Build a runtime from known inputs.
pub fn new(configs: List(InputConfig)) -> Runtime {
Runtime(inputs: from_configs(configs, []))
}
/// Handle one `phx-change`-style event.
///
/// Semantics:
/// - debounce stores pending payload
/// - debounce blur stores pending payload until blur
/// - throttle emits immediately then suppresses inside window
/// - change for one input resets timers for all other inputs
pub fn change(
runtime: Runtime,
input: String,
payload: String,
at_ms: Int,
) -> #(Runtime, Dispatch) {
let parsed = form.parse_payload(payload)
let inputs = reset_other_timers(runtime.inputs, input, [])
let #(inputs, dispatch) = apply_change(inputs, input, parsed, at_ms, [])
#(Runtime(inputs: inputs), dispatch)
}
/// Handle one blur event used by `phx-debounce="blur"`.
pub fn blur(
runtime: Runtime,
input: String,
at_ms: Int,
) -> #(Runtime, Dispatch) {
let #(inputs, dispatch) = apply_blur(runtime.inputs, input, at_ms, [])
#(Runtime(inputs: inputs), dispatch)
}
/// Flush due debounce timers.
pub fn tick(runtime: Runtime, at_ms: Int) -> #(Runtime, List(Dispatch)) {
let #(inputs, dispatches_rev) = flush_debounce(runtime.inputs, at_ms, [], [])
#(Runtime(inputs: inputs), list.reverse(dispatches_rev))
}
/// Handle one `phx-submit`-style event.
///
/// Submit resets existing change timers.
pub fn submit(
runtime: Runtime,
payload: String,
_at_ms: Int,
) -> #(Runtime, Dispatch) {
let inputs = clear_all_timers(runtime.inputs, [])
#(
Runtime(inputs: inputs),
DispatchSubmit(payload: form.parse_payload(payload)),
)
}
/// Stable dispatch label for tests and logs.
pub fn dispatch_label(dispatch: Dispatch) -> String {
case dispatch {
DispatchChange(_, _) -> "change"
DispatchSubmit(_) -> "submit"
Queued(_) -> "queued"
Suppressed(_, reason) -> "suppressed:" <> reason
NoDispatch -> "none"
}
}
/// Return pending status for one input.
pub fn has_pending(runtime: Runtime, input: String) -> Bool {
case find_input(runtime.inputs, input) {
Some(InputState(_, _, _, Some(_), _)) -> True
_ -> False
}
}
/// Return the current mode for one input.
pub fn mode(runtime: Runtime, input: String) -> Option(ChangeMode) {
case find_input(runtime.inputs, input) {
Some(InputState(_, mode, _, _, _)) -> Some(mode)
None -> None
}
}
fn from_configs(
configs: List(InputConfig),
states_rev: List(InputState),
) -> List(InputState) {
case configs {
[] -> list.reverse(states_rev)
[InputConfig(name, mode), ..rest] ->
from_configs(rest, [
InputState(
name: name,
mode: mode,
last_dispatch_at_ms: None,
pending_payload: None,
pending_since_ms: None,
),
..states_rev
])
}
}
fn apply_change(
states: List(InputState),
input: String,
payload: form.FormData,
at_ms: Int,
states_rev: List(InputState),
) -> #(List(InputState), Dispatch) {
case states {
[] -> #(
list.reverse(states_rev),
Suppressed(input: input, reason: "unknown_input"),
)
[state, ..rest] ->
case state.name == input {
False ->
apply_change(rest, input, payload, at_ms, [state, ..states_rev])
True ->
case state.mode {
Immediate -> {
let next =
InputState(
..state,
last_dispatch_at_ms: Some(at_ms),
pending_payload: None,
pending_since_ms: None,
)
#(
rebuild(states_rev, next, rest),
DispatchChange(input: input, payload: payload),
)
}
Debounce(wait_ms) ->
case wait_ms <= 0 {
True -> {
let next =
InputState(
..state,
last_dispatch_at_ms: Some(at_ms),
pending_payload: None,
pending_since_ms: None,
)
#(
rebuild(states_rev, next, rest),
DispatchChange(input: input, payload: payload),
)
}
False -> {
let next =
InputState(
..state,
pending_payload: Some(payload),
pending_since_ms: Some(at_ms),
)
#(rebuild(states_rev, next, rest), Queued(input: input))
}
}
DebounceBlur -> {
let next =
InputState(
..state,
pending_payload: Some(payload),
pending_since_ms: Some(at_ms),
)
#(rebuild(states_rev, next, rest), Queued(input: input))
}
Throttle(window_ms) ->
case
should_dispatch_throttle(
state.last_dispatch_at_ms,
at_ms,
window_ms,
)
{
True -> {
let next =
InputState(
..state,
last_dispatch_at_ms: Some(at_ms),
pending_payload: None,
pending_since_ms: None,
)
#(
rebuild(states_rev, next, rest),
DispatchChange(input: input, payload: payload),
)
}
False -> #(
rebuild(states_rev, state, rest),
Suppressed(input: input, reason: "throttled"),
)
}
}
}
}
}
fn apply_blur(
states: List(InputState),
input: String,
at_ms: Int,
states_rev: List(InputState),
) -> #(List(InputState), Dispatch) {
case states {
[] -> #(
list.reverse(states_rev),
Suppressed(input: input, reason: "unknown_input"),
)
[state, ..rest] ->
case state.name == input {
False -> apply_blur(rest, input, at_ms, [state, ..states_rev])
True ->
case state.mode, state.pending_payload {
DebounceBlur, Some(payload) -> {
let next =
InputState(
..state,
last_dispatch_at_ms: Some(at_ms),
pending_payload: None,
pending_since_ms: None,
)
#(
rebuild(states_rev, next, rest),
DispatchChange(input: input, payload: payload),
)
}
_, _ -> #(rebuild(states_rev, state, rest), NoDispatch)
}
}
}
}
fn flush_debounce(
states: List(InputState),
at_ms: Int,
states_rev: List(InputState),
dispatches_rev: List(Dispatch),
) -> #(List(InputState), List(Dispatch)) {
case states {
[] -> #(list.reverse(states_rev), dispatches_rev)
[state, ..rest] ->
case state.mode, state.pending_payload, state.pending_since_ms {
Debounce(wait_ms), Some(payload), Some(pending_since) ->
case at_ms - pending_since >= wait_ms {
True -> {
let next =
InputState(
..state,
last_dispatch_at_ms: Some(at_ms),
pending_payload: None,
pending_since_ms: None,
)
flush_debounce(rest, at_ms, [next, ..states_rev], [
DispatchChange(input: state.name, payload: payload),
..dispatches_rev
])
}
False ->
flush_debounce(rest, at_ms, [state, ..states_rev], dispatches_rev)
}
_, _, _ ->
flush_debounce(rest, at_ms, [state, ..states_rev], dispatches_rev)
}
}
}
fn reset_other_timers(
states: List(InputState),
selected_input: String,
states_rev: List(InputState),
) -> List(InputState) {
case states {
[] -> list.reverse(states_rev)
[state, ..rest] ->
case state.name == selected_input {
True -> reset_other_timers(rest, selected_input, [state, ..states_rev])
False ->
reset_other_timers(rest, selected_input, [
clear_timers(state),
..states_rev
])
}
}
}
fn clear_all_timers(
states: List(InputState),
states_rev: List(InputState),
) -> List(InputState) {
case states {
[] -> list.reverse(states_rev)
[state, ..rest] ->
clear_all_timers(rest, [clear_timers(state), ..states_rev])
}
}
fn clear_timers(state: InputState) -> InputState {
InputState(
..state,
last_dispatch_at_ms: None,
pending_payload: None,
pending_since_ms: None,
)
}
fn should_dispatch_throttle(
last_dispatch: Option(Int),
now_ms: Int,
window_ms: Int,
) -> Bool {
case window_ms <= 0 {
True -> True
False ->
case last_dispatch {
None -> True
Some(last) -> now_ms - last >= window_ms
}
}
}
fn find_input(states: List(InputState), name: String) -> Option(InputState) {
case states {
[] -> None
[state, ..rest] ->
case state.name == name {
True -> Some(state)
False -> find_input(rest, name)
}
}
}
fn rebuild(
states_rev: List(InputState),
updated: InputState,
rest: List(InputState),
) -> List(InputState) {
reverse_into([updated, ..states_rev], rest)
}
fn reverse_into(
left: List(InputState),
right: List(InputState),
) -> List(InputState) {
case left {
[] -> right
[entry, ..rest] -> reverse_into(rest, [entry, ..right])
}
}