///|
/// APG Helper Utilities
/// Common utilities for APG components including ID generation and keyboard navigation
///|
/// Global ID counter for auto-generated IDs
let id_counter : Ref[Int] = Ref::new(0)
///|
/// Generate a unique ID with a given prefix.
/// Useful for automatically creating IDs for tabs, panels, etc.
/// ```mbt check
/// test {
/// let id1 = @luna_components.auto_id("tab")
/// let id2 = @luna_components.auto_id("tab")
/// assert_true(id1 != id2)
/// }
/// ```
pub fn auto_id(prefix : String) -> String {
id_counter.val = id_counter.val + 1
prefix + "-" + id_counter.val.to_string()
}
///|
/// Reset the ID counter (useful for testing)
pub fn reset_id_counter() -> Unit {
id_counter.val = 0
}
///|
/// Common key codes for keyboard navigation
pub(all) enum KeyCode {
Enter
Space
ArrowUp
ArrowDown
ArrowLeft
ArrowRight
Home
End
Tab
Escape
} derive(Eq, Debug)
///|
pub impl Show for KeyCode with output(self, logger) {
logger.write_string(
match self {
Enter => "Enter"
Space => "Space"
ArrowUp => "ArrowUp"
ArrowDown => "ArrowDown"
ArrowLeft => "ArrowLeft"
ArrowRight => "ArrowRight"
Home => "Home"
End => "End"
Tab => "Tab"
Escape => "Escape"
},
)
}
///|
pub fn KeyCode::from_string(key : String) -> KeyCode? {
match key {
"Enter" => Some(Enter)
" " => Some(Space)
"ArrowUp" => Some(ArrowUp)
"ArrowDown" => Some(ArrowDown)
"ArrowLeft" => Some(ArrowLeft)
"ArrowRight" => Some(ArrowRight)
"Home" => Some(Home)
"End" => Some(End)
"Tab" => Some(Tab)
"Escape" => Some(Escape)
_ => None
}
}
///|
/// Boolean to ARIA string conversion
pub fn bool_to_aria(value : Bool) -> String {
if value {
"true"
} else {
"false"
}
}
// =============================================================================
// Keyboard Navigation Helpers
// =============================================================================
///|
/// Check if a key matches a KeyCode
pub fn key_matches(key : String, code : KeyCode) -> Bool {
match code {
Enter => key == "Enter"
Space => key == " "
ArrowUp => key == "ArrowUp"
ArrowDown => key == "ArrowDown"
ArrowLeft => key == "ArrowLeft"
ArrowRight => key == "ArrowRight"
Home => key == "Home"
End => key == "End"
Tab => key == "Tab"
Escape => key == "Escape"
}
}
///|
/// Check if a key is a toggle key (Space or Enter)
pub fn is_toggle_key(key : String) -> Bool {
key == " " || key == "Enter"
}
///|
/// Check if a key is an arrow key
pub fn is_arrow_key(key : String) -> Bool {
key == "ArrowUp" ||
key == "ArrowDown" ||
key == "ArrowLeft" ||
key == "ArrowRight"
}
///|
/// Check if a key is a horizontal arrow key
pub fn is_horizontal_arrow(key : String) -> Bool {
key == "ArrowLeft" || key == "ArrowRight"
}
///|
/// Check if a key is a vertical arrow key
pub fn is_vertical_arrow(key : String) -> Bool {
key == "ArrowUp" || key == "ArrowDown"
}
///|
/// Get direction from arrow key (-1 for up/left, 1 for down/right, 0 for other)
pub fn arrow_direction(key : String) -> Int {
match key {
"ArrowUp" | "ArrowLeft" => -1
"ArrowDown" | "ArrowRight" => 1
_ => 0
}
}
///|
/// Calculate next index for list navigation with wrapping
pub fn wrap_index(current : Int, direction : Int, length : Int) -> Int {
if length <= 0 {
return 0
}
let next = current + direction
if next < 0 {
length - 1
} else if next >= length {
0
} else {
next
}
}
///|
/// Calculate next index for list navigation without wrapping (clamped)
pub fn clamp_index(current : Int, direction : Int, length : Int) -> Int {
if length <= 0 {
return 0
}
let next = current + direction
if next < 0 {
0
} else if next >= length {
length - 1
} else {
next
}
}
///|
/// Get the key string from a keyboard event
fn get_event_key(e : @js.Any) -> String {
e._get("key").to_string()
}
///|
/// Create a toggle handler that responds to Space and Enter keys
pub fn make_toggle_handler(
on_toggle : () -> Unit,
) -> @luna.EventHandler[@js.Any] {
@luna.handler(fn(e : @js.Any) {
let key = get_event_key(e)
if is_toggle_key(key) {
on_toggle()
}
})
}
///|
/// Create a horizontal arrow key handler for tabs/sliders
/// - ArrowLeft: move to previous
/// - ArrowRight: move to next
/// - Home: move to first (optional)
/// - End: move to last (optional)
pub fn make_horizontal_nav_handler(
current_index : () -> Int,
length : Int,
on_change : (Int) -> Unit,
wrap? : Bool,
support_home_end? : Bool,
) -> @luna.EventHandler[@js.Any] {
let do_wrap = match wrap {
Some(w) => w
None => true
}
let home_end = match support_home_end {
Some(h) => h
None => true
}
@luna.handler(fn(e : @js.Any) {
let key = get_event_key(e)
if is_horizontal_arrow(key) {
let dir = arrow_direction(key)
let curr = current_index()
let next = if do_wrap {
wrap_index(curr, dir, length)
} else {
clamp_index(curr, dir, length)
}
if next != curr {
on_change(next)
}
} else if home_end && key == "Home" {
on_change(0)
} else if home_end && key == "End" {
on_change(length - 1)
}
})
}
///|
/// Create a vertical arrow key handler for radio groups/listboxes
/// - ArrowUp: move to previous
/// - ArrowDown: move to next
/// - Home: move to first (optional)
/// - End: move to last (optional)
pub fn make_vertical_nav_handler(
current_index : () -> Int,
length : Int,
on_change : (Int) -> Unit,
wrap? : Bool,
support_home_end? : Bool,
) -> @luna.EventHandler[@js.Any] {
let do_wrap = match wrap {
Some(w) => w
None => true
}
let home_end = match support_home_end {
Some(h) => h
None => true
}
@luna.handler(fn(e : @js.Any) {
let key = get_event_key(e)
if is_vertical_arrow(key) {
let dir = arrow_direction(key)
let curr = current_index()
let next = if do_wrap {
wrap_index(curr, dir, length)
} else {
clamp_index(curr, dir, length)
}
if next != curr {
on_change(next)
}
} else if home_end && key == "Home" {
on_change(0)
} else if home_end && key == "End" {
on_change(length - 1)
}
})
}
///|
/// Create a slider keyboard handler
/// - ArrowUp/Right: increase by step
/// - ArrowDown/Left: decrease by step
/// - Home: set to min
/// - End: set to max
/// - PageUp: increase by large step (optional)
/// - PageDown: decrease by large step (optional)
pub fn make_slider_handler(
current_value : () -> Double,
min : Double,
max : Double,
step : Double,
on_change : (Double) -> Unit,
large_step? : Double,
) -> @luna.EventHandler[@js.Any] {
let big_step = match large_step {
Some(s) => s
None => step * 10.0
}
@luna.handler(fn(e : @js.Any) {
let key = get_event_key(e)
let curr = current_value()
let new_val = match key {
"ArrowUp" | "ArrowRight" => {
let v = curr + step
if v > max {
max
} else {
v
}
}
"ArrowDown" | "ArrowLeft" => {
let v = curr - step
if v < min {
min
} else {
v
}
}
"PageUp" => {
let v = curr + big_step
if v > max {
max
} else {
v
}
}
"PageDown" => {
let v = curr - big_step
if v < min {
min
} else {
v
}
}
"Home" => min
"End" => max
_ => curr
}
if new_val != curr {
on_change(new_val)
}
})
}
///|
/// Create an escape key handler
pub fn make_escape_handler(
on_escape : () -> Unit,
) -> @luna.EventHandler[@js.Any] {
@luna.handler(fn(e : @js.Any) {
let key = get_event_key(e)
if key == "Escape" {
on_escape()
}
})
}
///|
/// Calculate slider value from mouse position
fn calc_slider_value(
client_x : Double,
rect_left : Double,
rect_width : Double,
min : Double,
max : Double,
step : Double,
) -> Double {
let offset_x = client_x - rect_left
let ratio = if rect_width > 0.0 { offset_x / rect_width } else { 0.0 }
let clamped_ratio = if ratio < 0.0 {
0.0
} else if ratio > 1.0 {
1.0
} else {
ratio
}
let range = max - min
let raw_value = min + clamped_ratio * range
// Round to nearest step
let steps = ((raw_value - min) / step).round()
let stepped_value = min + steps * step
// Clamp to range
if stepped_value < min {
min
} else if stepped_value > max {
max
} else {
stepped_value
}
}
///|
/// Create a slider mousedown handler that enables drag tracking
/// Sets up document-level mousemove/mouseup listeners for smooth dragging
pub fn make_slider_drag_handler(
min : Double,
max : Double,
step : Double,
on_change : (Double) -> Unit,
) -> @luna.EventHandler[@js.Any] {
@luna.handler(fn(e : @js.Any) {
let target = e._get("currentTarget")
let rect = target._call("getBoundingClientRect", [])
let rect_left : Double = rect._get("left").cast()
let rect_width : Double = rect._get("width").cast()
let client_x : Double = e._get("clientX").cast()
// Update value immediately on mousedown
let value = calc_slider_value(
client_x, rect_left, rect_width, min, max, step,
)
on_change(value)
// Set up document-level listeners for drag
let doc = @js.global_this()._get("document")
// Create mousemove handler
let move_handler = @js.from_fn1(fn(move_e : @js.Any) {
let move_x : Double = move_e._get("clientX").cast()
let v = calc_slider_value(move_x, rect_left, rect_width, min, max, step)
on_change(v)
})
// Create mouseup handler that removes listeners
let up_handler_ref : Ref[@js.Any] = Ref::new(@js.undefined())
let up_handler = @js.from_fn1(fn(_up_e : @js.Any) {
let _ = doc._call("removeEventListener", [
@js.any("mousemove"),
move_handler,
])
let _ = doc._call("removeEventListener", [
@js.any("mouseup"),
up_handler_ref.val,
])
})
up_handler_ref.val = up_handler
// Add listeners
let _ = doc._call("addEventListener", [@js.any("mousemove"), move_handler])
let _ = doc._call("addEventListener", [@js.any("mouseup"), up_handler])
})
}