///|
/// APG Spinbutton Pattern
/// https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/
///
/// A spinbutton is an input widget that restricts its value to a set
/// or range of discrete values. Unlike slider, it supports text input.
///
/// Keyboard Interaction:
/// - Up Arrow: Increase value by step
/// - Down Arrow: Decrease value by step
/// - Home: Set to minimum (if defined)
/// - End: Set to maximum (if defined)
/// - PageUp: Increase by large step
/// - PageDown: Decrease by large step
///
/// Note: Left/Right arrows are not used (reserved for text editing)
///
/// ARIA:
/// - role="spinbutton"
/// - aria-valuenow (required)
/// - aria-valuemin, aria-valuemax (when bounds exist)
/// - aria-valuetext (optional, for human-readable value)
///|
/// Create a basic spinbutton.
///
/// Parameters:
/// - value: Current numeric value
/// - min: Minimum value (optional)
/// - max: Maximum value (optional)
/// - step: Step increment (default: 1)
/// - aria_label: Accessible label
/// - aria_labelledby: ID of labelling element
/// - aria_valuetext: Human-readable value text
/// - disabled: Whether the spinbutton is disabled
/// - is_readonly: Whether the spinbutton is read-only
/// - on_keydown: Keydown handler
/// - on_input: Input handler for text changes
pub fn[E] spinbutton(
value : Double,
min? : Double,
max? : Double,
step? : Double,
aria_label? : String,
aria_labelledby? : String,
aria_valuetext? : String,
disabled? : Bool,
is_readonly? : Bool,
on_keydown? : @luna.EventHandler[E],
on_input? : @luna.EventHandler[E],
) -> @luna.Node[E, String] {
let attrs : Array[(String, @luna.Attr[E, String])] = [
("role", @luna.attr_static("spinbutton")),
("tabindex", @luna.attr_static("0")),
("aria-valuenow", @luna.attr_static(value.to_string())),
("inputmode", @luna.attr_static("numeric")),
]
if min is Some(m) {
attrs.push(("aria-valuemin", @luna.attr_static(m.to_string())))
}
if max is Some(m) {
attrs.push(("aria-valuemax", @luna.attr_static(m.to_string())))
}
if step is Some(s) {
attrs.push(("data-step", @luna.attr_static(s.to_string())))
}
if aria_label is Some(label) {
attrs.push(("aria-label", @luna.attr_static(label)))
}
if aria_labelledby is Some(id) {
attrs.push(("aria-labelledby", @luna.attr_static(id)))
}
if aria_valuetext is Some(text) {
attrs.push(("aria-valuetext", @luna.attr_static(text)))
}
if disabled is Some(true) {
attrs.push(("aria-disabled", @luna.attr_static("true")))
}
if is_readonly is Some(true) {
attrs.push(("aria-readonly", @luna.attr_static("true")))
}
if on_keydown is Some(handler) {
attrs.push(("keydown", @luna.attr_handler(handler)))
}
if on_input is Some(handler) {
attrs.push(("input", @luna.attr_handler(handler)))
}
// Using input element for text editing support
@luna.h("input", attrs, [])
}
///|
/// Create a dynamic spinbutton with signal-based value.
pub fn[E] spinbutton_dyn(
value : @resource.Signal[Double],
min? : Double,
max? : Double,
aria_label? : String,
aria_valuetext_fn? : (Double) -> String,
on_keydown? : @luna.EventHandler[E],
on_input? : @luna.EventHandler[E],
) -> @luna.Node[E, String] {
let attrs : Array[(String, @luna.Attr[E, String])] = [
("role", @luna.attr_static("spinbutton")),
("tabindex", @luna.attr_static("0")),
("aria-valuenow", @luna.attr_dynamic(fn() { value.get().to_string() })),
("inputmode", @luna.attr_static("numeric")),
("value", @luna.attr_dynamic(fn() { value.get().to_string() })),
]
if min is Some(m) {
attrs.push(("aria-valuemin", @luna.attr_static(m.to_string())))
}
if max is Some(m) {
attrs.push(("aria-valuemax", @luna.attr_static(m.to_string())))
}
if aria_label is Some(label) {
attrs.push(("aria-label", @luna.attr_static(label)))
}
if aria_valuetext_fn is Some(fn_) {
attrs.push(
("aria-valuetext", @luna.attr_dynamic(fn() { fn_(value.get()) })),
)
}
if on_keydown is Some(handler) {
attrs.push(("keydown", @luna.attr_handler(handler)))
}
if on_input is Some(handler) {
attrs.push(("input", @luna.attr_handler(handler)))
}
@luna.h("input", attrs, [])
}
///|
/// Create a spinbutton keyboard handler.
/// Handles Up/Down arrows, Home/End, PageUp/PageDown.
pub fn make_spinbutton_handler(
current_value : () -> Double,
min : Double?,
max : Double?,
step : Double,
on_change : (Double) -> Unit,
large_step? : Double,
) -> @luna.EventHandler[@js.Any] {
let big_step = large_step.unwrap_or(step * 10.0)
@luna.handler(fn(e : @js.Any) {
let key : String = e._get("key").cast()
let curr = current_value()
let new_val : Double? = match key {
"ArrowUp" => {
let v = curr + step
match max {
Some(m) => Some(if v > m { m } else { v })
None => Some(v)
}
}
"ArrowDown" => {
let v = curr - step
match min {
Some(m) => Some(if v < m { m } else { v })
None => Some(v)
}
}
"PageUp" => {
let v = curr + big_step
match max {
Some(m) => Some(if v > m { m } else { v })
None => Some(v)
}
}
"PageDown" => {
let v = curr - big_step
match min {
Some(m) => Some(if v < m { m } else { v })
None => Some(v)
}
}
"Home" =>
match min {
Some(m) => Some(m)
None => None
}
"End" =>
match max {
Some(m) => Some(m)
None => None
}
_ => None
}
if new_val is Some(v) {
if v != curr {
let _ = e._call("preventDefault", [])
on_change(v)
}
}
})
}
///|
/// Create a spinbutton input handler for text input.
/// Validates and clamps input to min/max bounds.
pub fn make_spinbutton_input_handler(
min : Double?,
max : Double?,
on_change : (Double) -> Unit,
fallback_value : () -> Double,
) -> @luna.EventHandler[@js.Any] {
@luna.handler(fn(e : @js.Any) {
let target = e._get("target")
let input_str : String = target._get("value").cast()
// Try to parse the input using JavaScript's parseFloat
let v : Double = @js.global_this()
._call("parseFloat", [@js.any(input_str)])
.cast()
// Check if it's a valid number (not NaN)
let is_nan : Bool = @js.global_this()._call("isNaN", [@js.any(v)]).cast()
if is_nan {
// Invalid input - restore previous value
let prev = fallback_value()
let _ = target._call("setAttribute", [
@js.any("value"),
@js.any(prev.to_string()),
])
} else {
// Clamp to bounds
let clamped = match (min, max) {
(Some(mi), Some(ma)) =>
if v < mi {
mi
} else if v > ma {
ma
} else {
v
}
(Some(mi), None) => if v < mi { mi } else { v }
(None, Some(ma)) => if v > ma { ma } else { v }
(None, None) => v
}
on_change(clamped)
}
})
}
///|
/// Create a complete interactive spinbutton with increment/decrement buttons.
///
/// Parameters:
/// - value: Signal containing the current value
/// - min: Minimum value
/// - max: Maximum value
/// - step: Step increment (default: 1)
/// - large_step: Large step for PageUp/PageDown (default: step * 10)
/// - aria_label: Accessible label
/// - aria_valuetext_fn: Function to convert value to human-readable text
pub fn spinbutton_interactive(
value : @resource.Signal[Double],
min? : Double,
max? : Double,
step? : Double,
large_step? : Double,
aria_label? : String,
aria_valuetext_fn? : (Double) -> String,
) -> @luna.Node[@js.Any, String] {
let step_val = step.unwrap_or(1.0)
let clamp = fn(v : Double) -> Double {
let v1 = match min {
Some(m) => if v < m { m } else { v }
None => v
}
match max {
Some(m) => if v1 > m { m } else { v1 }
None => v1
}
}
let keydown_handler = make_spinbutton_handler(
fn() { value.get() },
min,
max,
step_val,
fn(v) { value.set(v) },
large_step?,
)
let input_handler = make_spinbutton_input_handler(
min,
max,
fn(v) { value.set(v) },
fn() { value.get() },
)
let decrement = fn() {
let v = value.get() - step_val
value.set(clamp(v))
}
let increment = fn() {
let v = value.get() + step_val
value.set(clamp(v))
}
// Build the input element
let input_attrs : Array[(String, @luna.Attr[@js.Any, String])] = [
("type", @luna.attr_static("text")),
("role", @luna.attr_static("spinbutton")),
("inputmode", @luna.attr_static("numeric")),
("aria-valuenow", @luna.attr_dynamic(fn() { value.get().to_string() })),
("value", @luna.attr_dynamic(fn() { value.get().to_string() })),
("keydown", @luna.attr_handler(keydown_handler)),
("change", @luna.attr_handler(input_handler)),
]
if min is Some(m) {
input_attrs.push(("aria-valuemin", @luna.attr_static(m.to_string())))
}
if max is Some(m) {
input_attrs.push(("aria-valuemax", @luna.attr_static(m.to_string())))
}
if aria_label is Some(label) {
input_attrs.push(("aria-label", @luna.attr_static(label)))
}
if aria_valuetext_fn is Some(fn_) {
input_attrs.push(
("aria-valuetext", @luna.attr_dynamic(fn() { fn_(value.get()) })),
)
}
@luna.h("div", [("class", @luna.attr_static("spinbutton-container"))], [
@luna.h(
"button",
[
("type", @luna.attr_static("button")),
("class", @luna.attr_static("spinbutton-decrement")),
("aria-label", @luna.attr_static("Decrease")),
("tabindex", @luna.attr_static("-1")),
("click", @luna.attr_handler(@luna.handler(fn(_) { decrement() }))),
],
[@luna.text("-")],
),
@luna.h("input", input_attrs, []),
@luna.h(
"button",
[
("type", @luna.attr_static("button")),
("class", @luna.attr_static("spinbutton-increment")),
("aria-label", @luna.attr_static("Increase")),
("tabindex", @luna.attr_static("-1")),
("click", @luna.attr_handler(@luna.handler(fn(_) { increment() }))),
],
[@luna.text("+")],
),
])
}
///|
/// Create a native HTML number input.
/// This is the simplest option when custom styling is not needed.
pub fn[E] spinbutton_native(
name : String,
value : Double,
min? : Double,
max? : Double,
step? : Double,
aria_label? : String,
id? : String,
) -> @luna.Node[E, String] {
let input_id = id.unwrap_or("spinbutton-" + name)
let attrs : Array[(String, @luna.Attr[E, String])] = [
("type", @luna.attr_static("number")),
("name", @luna.attr_static(name)),
("id", @luna.attr_static(input_id)),
("value", @luna.attr_static(value.to_string())),
]
if min is Some(m) {
attrs.push(("min", @luna.attr_static(m.to_string())))
}
if max is Some(m) {
attrs.push(("max", @luna.attr_static(m.to_string())))
}
if step is Some(s) {
attrs.push(("step", @luna.attr_static(s.to_string())))
}
if aria_label is Some(label) {
attrs.push(("aria-label", @luna.attr_static(label)))
}
@luna.h("input", attrs, [])
}