///|
pub(all) enum SelectVariant {
Form
Filter
}
///|
const SelectTriggerStyle : String = "display:flex;align-items:center;gap:8px;width:100%;height:32px;margin:0;padding:4px 12px;border:1px solid var(--sumi-stroke,rgba(204,221,255,0.1));border-radius:var(--sumi-radius,8px);background:var(--sumi-block,rgba(255,255,255,0.08));font-size:13px;line-height:22px;color:var(--sumi-text,#ffffff);text-align:left;cursor:pointer;user-select:none;appearance:none;-webkit-appearance:none;outline:none"
///|
const SelectFilterTriggerStyle : String = "display:flex;align-items:center;gap:8px;height:28px;margin:0;padding:3px 8px;border:0.5px solid var(--sumi-stroke-primary,rgba(255,255,255,0.08));border-radius:6px;background:transparent;font-size:12px;line-height:20px;text-align:left;cursor:pointer;user-select:none"
///|
const SelectFilterOpenStyle : String = "display:flex;align-items:center;gap:8px;flex:1 1 0%;min-width:0;margin:0;padding:0;border:none;background:transparent;font:inherit;color:inherit;text-align:left;cursor:pointer;appearance:none;-webkit-appearance:none;outline:none"
///|
const SelectFilterClearStyle : String = "display:inline-flex;flex:none;align-items:center;justify-content:center;width:16px;height:16px;margin:0;padding:0;border:none;background:transparent;color:var(--sumi-text-placeholder,rgba(255,255,255,0.35));cursor:pointer;appearance:none;-webkit-appearance:none;outline:none"
///|
const SelectFilterCaretStyle : String = "display:inline-flex;flex:none;align-items:center;justify-content:center;transition:transform 200ms cubic-bezier(0.4,0,0.2,1)"
///|
const SelectLabelStyle : String = "flex:1 1 0%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
///|
fn select_trigger_color(
disabled : Bool,
filtered : Bool,
open : Bool,
) -> String {
if disabled {
"color:var(--sumi-text-disabled,rgba(255,255,255,0.2))"
} else if filtered || open {
"color:var(--sumi-text,#ffffff)"
} else {
"color:var(--sumi-text-secondary,rgba(255,255,255,0.7))"
}
}
///|
fn select_caret_color(disabled : Bool, open : Bool) -> String {
if disabled {
"color:var(--sumi-text-disabled,rgba(255,255,255,0.2))"
} else if open {
"color:var(--sumi-text-secondary,rgba(255,255,255,0.7))"
} else {
"color:var(--sumi-text-placeholder,rgba(255,255,255,0.35))"
}
}
///|
/// The compact filter-trigger look: a hairline box whose label is the filter
/// name until a value is selected, then the value with a clear affordance.
/// The open state brightens and flips the caret; disabled dims both.
fn select_filter_trigger(
label : @html.Html,
filtered : Bool,
open : Bool,
disabled : Bool,
on_clear : @cmd.Cmd?,
element_attrs : @html.Attrs,
) -> @html.Html {
let clear_button : Array[@html.Html] = if filtered {
[
@html.button(
style=[@internal.SumiBoxSizing, SelectFilterClearStyle],
type_="button",
disabled~,
attrs=@html.Attrs::build()
.data_set("slot", "select-filter-clear")
.aria_label("Clear filter")
.on_click(fn(ev) {
ev.stop_propagation()
on_clear.unwrap_or(@cmd.none)
}),
[@primitives.icon_close_circle(size=16)],
),
]
} else {
[]
}
let caret : Array[@html.Html] = if filtered {
[]
} else {
[
@html.span(
style=[
SelectFilterCaretStyle,
select_caret_color(disabled, open),
if open {
"transform:rotate(180deg)"
} else {
"transform:none"
},
],
attrs=@html.Attrs::build()
.data_set("slot", "select-filter-caret")
.aria_hidden("true"),
[@primitives.icon_caret_down(size=16)],
),
]
}
@html.div(
style=@internal.sumi_styles(
[
@internal.SumiBoxSizing,
@internal.SumiFontSans,
@internal.SumiTextRendering,
@internal.SumiTransition,
SelectFilterTriggerStyle,
select_trigger_color(disabled, filtered, open),
@internal.sumi_disabled_style(disabled),
],
[],
),
attrs=element_attrs,
[
@html.button(
style=[@internal.SumiBoxSizing, SelectFilterOpenStyle],
type_="button",
disabled~,
attrs=@html.Attrs::build()
.data_set("slot", "select-filter-open")
.aria_haspopup("menu")
.aria_expanded(@internal.sumi_bool(open)),
[@html.span(style=[SelectLabelStyle], [label]), ..caret],
),
..clear_button,
],
)
}
///|
/// A form-style single select: an input-look trigger opening the compact
/// menu. Fully controlled — the caller owns `open` and `value`. Built on
/// `dropdown_menu`, so outside click, Escape, and arrow-key navigation all
/// behave the same.
///
/// `variant=Filter` swaps in the compact toolbar trigger: the label is the
/// filter name (`placeholder`) until a value is selected, then the selected
/// label with a clear affordance that emits `on_clear` (without opening the
/// menu). The caller resets `value` in response.
///
/// ```moonbit nocheck
/// select(
/// options=[@overlays.MenuItem::new("png", "PNG"), @overlays.MenuItem::new("webp", "WebP")],
/// value=format,
/// placeholder="Export format",
/// open=select_open,
/// on_open_change=set_select_open.map(v => _ => v),
/// on_select=set_format.map(v => _ => v),
/// )
/// ```
pub fn select(
options~ : Array[@overlays.MenuItem],
value~ : String,
placeholder? : String = "Select",
open~ : Bool,
on_open_change? : @internal.Emit[Bool],
on_select? : @internal.Emit[String],
variant? : SelectVariant = Form,
on_clear? : @cmd.Cmd,
disabled? : Bool = false,
id? : String,
class? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
) -> @html.Html {
let current = options.iter().find_first(option => option.value == value)
let selected_label : @html.Html? = current.map(option => {
@html.text(option.label)
})
let base_attrs = @internal.sumi_attrs(attrs).data_set(
"slot", "select-trigger",
)
let trigger = match variant {
Form => {
let label : @html.Html = match selected_label {
Some(label) => label
None =>
@html.span(
style=["color:var(--sumi-text-placeholder,rgba(255,255,255,0.35))"],
placeholder,
)
}
let trigger_attrs = base_attrs
.data_set("variant", "form")
.aria_haspopup("menu")
.aria_expanded(@internal.sumi_bool(open))
if disabled {
ignore(trigger_attrs.aria_disabled("true"))
}
@html.button(
style=@internal.sumi_styles(
[
@internal.SumiBoxSizing,
@internal.SumiFontSans,
@internal.SumiTextRendering,
@internal.SumiTransition,
SelectTriggerStyle,
@internal.sumi_disabled_style(disabled),
],
[],
),
type_="button",
disabled~,
attrs=trigger_attrs,
[
@html.span(style=[SelectLabelStyle], [label]),
@overlays.menu_chevron(),
],
)
}
Filter => {
let label : @html.Html = selected_label.unwrap_or(@html.text(placeholder))
let trigger_attrs = base_attrs.data_set("variant", "filter")
if disabled {
ignore(trigger_attrs.aria_disabled("true"))
}
select_filter_trigger(
label,
value != "",
open,
disabled,
on_clear,
trigger_attrs,
)
}
}
let (menu_style, trigger_style, root_style) = match variant {
Form =>
(
["width:100%"],
["display:flex;width:100%"],
["display:block;width:100%"],
)
Filter => ([], ["display:inline-flex"], ["display:inline-block"])
}
@overlays.dropdown_menu(
items=@overlays.MenuEntry::items(options),
trigger~,
open~,
on_open_change?,
selected=value,
on_select?,
menu_style~,
trigger_style~,
id?,
class?,
style=@internal.sumi_styles(root_style, style),
)
}