///|
const SliderFieldStyle : String = "display:flex;flex-direction:column;gap:12px;width:100%"
///|
const SliderFieldTitleStyle : String = "display:flex;align-items:center;width:100%;min-height:16px"
///|
const SliderFieldLabelStyle : String = "flex:1 1 0%;min-width:0;font-size:12px;line-height:12px;color:var(--sumi-text-placeholder,rgba(255,255,255,0.35))"
///|
const SliderFieldAutoStyle : String = "display:flex;align-items:center;gap:8px;flex:none;font-size:12px;line-height:20px"
///|
const SliderFieldBodyStyle : String = "display:flex;align-items:center;gap:24px;width:100%"
///|
const SliderFieldTrackColumnStyle : String = "display:flex;flex:1 1 0%;min-width:0;flex-direction:column;gap:8px;padding-top:4px"
///|
const SliderFieldTicksStyle : String = "position:absolute;inset:0;pointer-events:none"
///|
const SliderFieldTickStyle : String = "position:absolute;top:4px;width:1px;height:4px;border-radius:11px;background:var(--sumi-text-disabled,rgba(255,255,255,0.2))"
///|
const SliderFieldTickLabelsStyle : String = "position:relative;width:100%;height:14px;font-size:10px;line-height:18px;font-weight:500;color:var(--sumi-text-placeholder,rgba(255,255,255,0.35))"
///|
const SliderFieldTickLabelStyle : String = "position:absolute;top:0;white-space:nowrap"
///|
const SliderFieldNumberStyle : String = "display:flex;flex:none;align-items:center;gap:4px;width:90px;height:36px;padding:3px 4px 3px 8px;border-radius:var(--sumi-radius,8px);background:var(--sumi-block,rgba(255,255,255,0.08))"
///|
const SliderFieldInputStyle : String = "flex:1 1 0%;min-width:0;margin:0;padding:0;border:none;background:transparent;font-size:12px;line-height:20px;color:var(--sumi-text,#ffffff);outline:none;text-overflow:ellipsis;appearance:none;-webkit-appearance:none"
///|
const SliderFieldUnitStyle : String = "flex:none;font-size:13px;line-height:22px;color:var(--sumi-text-placeholder,rgba(255,255,255,0.35))"
///|
fn slider_field_clamp(value : Int, min : Int, max : Int) -> Int {
if value < min {
min
} else if value > max {
max
} else {
value
}
}
///|
/// Parse an integer, yielding `None` on invalid input.
fn slider_field_parse(text : String) -> Int? {
Some(@string.parse_int(text)) catch {
_ => None
}
}
///|
/// Horizontal alignment of a tick label: the first label hugs the left edge,
/// the last hugs the right edge, the rest center on their tick.
fn slider_field_tick_position(pct : Int, index : Int, count : Int) -> String {
if count <= 1 || index == 0 {
"left:0%;transform:none"
} else if index == count - 1 {
"left:100%;transform:translateX(-100%)"
} else {
"left:\{pct}%;transform:translateX(-50%)"
}
}
///|
fn slider_field_tick_label(
pct : Int,
tick : Int,
index : Int,
count : Int,
) -> @html.Html {
let position = slider_field_tick_position(pct, index, count)
@html.span(style=[SliderFieldTickLabelStyle, position], tick.to_string())
}
///|
/// A labelled numeric field composing the slider with tick marks, tick
/// labels, and a number box carrying the unit suffix — the duration-field
/// pattern. Fully controlled: the parent owns `value` (and `auto` when the
/// optional auto row is shown by passing it). The slider fires `on_input`
/// per tick and `on_commit` on release; the number box commits on Enter or
/// blur (Escape reverts), firing both.
///
/// `ticks` lists the values that get a track mark and a label below; the
/// first and last marks stay invisible like the design's end caps.
///
/// ```moonbit nocheck
/// slider_field(
/// value=duration,
/// min=0, max=180,
/// label="Total duration",
/// unit="s",
/// ticks=[0, 30, 60, 90, 120, 150, 180],
/// on_input=set_duration.map(v => _ => v),
/// )
/// ```
pub fn slider_field(
value~ : Int,
min? : Int = 0,
max? : Int = 100,
step? : Int = 1,
label? : String,
unit? : String = "",
ticks? : Array[Int] = [],
auto? : Bool,
auto_label? : String = "Auto",
on_auto_change? : @internal.Emit[Bool],
on_input? : @internal.Emit[Int],
on_commit? : @internal.Emit[Int],
disabled? : Bool = false,
id? : String,
class? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
) -> @html.Html {
let element_attrs = @internal.sumi_attrs(attrs).data_set(
"slot", "slider-field",
)
let range = max - min
let percent_of = fn(tick : Int) -> Int {
if range <= 0 {
0
} else {
(slider_field_clamp(tick, min, max) - min) * 100 / range
}
}
let auto_row : Array[@html.Html] = match auto {
Some(is_auto) => {
let auto_color = if is_auto {
"color:var(--sumi-text-placeholder,rgba(255,255,255,0.35))"
} else {
"color:var(--sumi-text-secondary,rgba(255,255,255,0.7))"
}
[
@html.div(
style=[
@internal.SumiBoxSizing,
@internal.SumiFontNum,
SliderFieldAutoStyle,
auto_color,
],
attrs=@html.Attrs::build().data_set("slot", "slider-field-auto"),
[
@html.span(style=[], auto_label),
switch(checked=is_auto, on_change?=on_auto_change, disabled~),
],
),
]
}
None => []
}
let title_row : Array[@html.Html] = match label {
Some(text) =>
[
@html.div(
style=[
@internal.SumiBoxSizing,
@internal.SumiFontSans,
SliderFieldTitleStyle,
],
attrs=@html.Attrs::build().data_set("slot", "slider-field-title"),
[
@html.span(
style=[@internal.SumiFontSans, SliderFieldLabelStyle],
attrs=@html.Attrs::build().data_set("slot", "slider-field-label"),
text,
),
..auto_row,
],
),
]
None => []
}
let tick_count = ticks.length()
let tick_marks = ticks.mapi(fn(i, tick) {
@html.span(
style=[
SliderFieldTickStyle,
"left:\{percent_of(tick)}%",
if i == 0 || i == tick_count - 1 {
"opacity:0"
} else {
"opacity:1"
},
],
attrs=@html.Attrs::build()
.data_set("slot", "slider-field-tick")
.aria_hidden("true"),
([] : Array[@html.Html]),
)
})
let tick_labels = ticks.mapi(fn(i, tick) {
slider_field_tick_label(percent_of(tick), tick, i, tick_count)
})
let slider_wrap = @html.div(style=["position:relative;width:100%"], [
slider(value~, min~, max~, step~, on_input?, on_commit?, disabled~),
@html.div(
style=[SliderFieldTicksStyle],
attrs=@html.Attrs::build().aria_hidden("true"),
tick_marks,
),
])
let commit = fn(text : String) -> @cmd.Cmd {
match slider_field_parse(text) {
Some(v) => {
let clamped = slider_field_clamp(v, min, max)
if clamped == value {
@cmd.none
} else {
let cmds : Array[@cmd.Cmd] = []
if on_input is Some(emit) {
cmds.push(emit(clamped))
}
if on_commit is Some(emit) {
cmds.push(emit(clamped))
}
@cmd.batch(cmds)
}
}
None => @cmd.none
}
}
let number_box = @html.div(
style=[
@internal.SumiBoxSizing,
@internal.SumiFontNum,
@internal.SumiTextRendering,
SliderFieldNumberStyle,
],
attrs=@html.Attrs::build().data_set("slot", "slider-field-number"),
[
@html.input(
input_type=Text,
value=value.to_string(),
read_only=disabled,
style=[
@internal.SumiBoxSizing,
@internal.SumiFontNum,
SliderFieldInputStyle,
],
attrs=@html.Attrs::build()
.data_set("slot", "slider-field-input")
.data_set("value", value.to_string())
.aria_label(label.unwrap_or("Value"))
.on_blur(fn(ev) {
match ev.target().to_html_input_element() {
Some(input) => {
let text = input.value()
let cmd = commit(text)
input.set_value(value.to_string())
cmd
}
None => @cmd.none
}
}),
),
@html.span(
style=[@internal.SumiFontNum, SliderFieldUnitStyle],
attrs=@html.Attrs::build().data_set("slot", "slider-field-unit"),
unit,
),
],
)
@html.div(
style=@internal.sumi_styles(
[
@internal.SumiBoxSizing,
@internal.SumiFontSans,
@internal.SumiTextRendering,
SliderFieldStyle,
],
style,
),
id?,
class?,
attrs=element_attrs,
[
..title_row,
@html.div(
style=[@internal.SumiBoxSizing, SliderFieldBodyStyle],
attrs=@html.Attrs::build().data_set("slot", "slider-field-body"),
[
@html.div(
style=[@internal.SumiBoxSizing, SliderFieldTrackColumnStyle],
[
slider_wrap,
@html.div(
style=[@internal.SumiFontNum, SliderFieldTickLabelsStyle],
attrs=@html.Attrs::build()
.data_set("slot", "slider-field-tick-labels")
.aria_hidden("true"),
tick_labels,
),
],
),
number_box,
],
),
],
)
}