///|
/// One row in a `checkbox_menu` group: a label with an optional leading
/// icon that toggles independently of the other rows.
pub(all) struct CheckboxMenuItem {
value : String
label : String
icon : @html.Html?
checked : Bool
disabled : Bool
}
///|
pub fn CheckboxMenuItem::new(
value : String,
label : String,
icon? : @html.Html,
checked? : Bool = false,
disabled? : Bool = false,
) -> CheckboxMenuItem {
{ value, label, icon, checked, disabled, }
}
///|
/// A titled section of a `checkbox_menu`. Passing `clear_label` renders the
/// small text action at the title row's right edge (meant to clear the
/// group's selection); without a `title` it is ignored. A hairline divider
/// is inserted between consecutive groups, never after the last one.
pub(all) struct CheckboxMenuGroup {
title : String?
clear_label : String?
on_clear : @cmd.Cmd?
items : Array[CheckboxMenuItem]
}
///|
pub fn CheckboxMenuGroup::new(
items : Array[CheckboxMenuItem],
title? : String,
clear_label? : String,
on_clear? : @cmd.Cmd,
) -> CheckboxMenuGroup {
{ title, clear_label, on_clear, items, }
}
///|
const CheckboxMenuStyle : String = "display:flex;flex-direction:column;padding:4px;border-radius:var(--sumi-radius-lg,12px);background:var(--sumi-menu,#262626);box-shadow:var(--sumi-shadow-menu,0px 8px 56px 0px rgba(0,0,0,0.24));overflow:hidden"
///|
const CheckboxMenuBodyStyle : String = "display:flex;flex:1 1 0%;min-height:0;flex-direction:column;gap:4px;overflow-y:auto"
///|
const CheckboxMenuGroupStyle : String = "display:flex;flex-direction:column;gap:4px"
///|
const CheckboxMenuTitleStyle : String = "display:flex;align-items:center;justify-content:space-between;padding:8px 12px 4px;font-size:12px;line-height:20px;color:var(--sumi-text-caption,rgba(255,255,255,0.5));user-select:none"
///|
const CheckboxMenuClearStyle : String = "display:inline-flex;align-items:center;justify-content:center;height:20px;margin:0;padding:0;border:none;background:transparent;font-size:12px;line-height:20px;color:var(--sumi-text-caption,rgba(255,255,255,0.5));cursor:pointer;appearance:none;-webkit-appearance:none;outline:none"
///|
const CheckboxMenuItemStyle : String = "display:flex;align-items:center;gap:8px;width:100%;height:36px;max-height:36px;min-height:36px;padding:9px 10px 9px 12px;border:none;border-radius:var(--sumi-radius,8px);background:transparent;font-size:13px;line-height:22px;font-weight:400;color:var(--sumi-text,#ffffff);text-align:left;cursor:pointer;user-select:none;appearance:none;-webkit-appearance:none;outline:none"
///|
const CheckboxMenuIconStyle : String = "display:inline-flex;flex:none;align-items:center;justify-content:center;width:16px;height:16px"
///|
const CheckboxMenuLabelStyle : String = "flex:1 1 0%;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
///|
const CheckboxMenuCheckStyle : String = "position:relative;flex:none;width:20px;height:20px;overflow:hidden;transition:opacity 100ms cubic-bezier(0.4,0,0.2,1)"
///|
const CheckboxMenuDividerStyle : String = "display:flex;flex:none;height:4px;max-height:4px;min-height:4px;padding:0 12px"
///|
const CheckboxMenuDividerLineStyle : String = "width:100%;height:1px;background:var(--sumi-stroke-divider,rgba(255,255,255,0.12))"
///|
const CheckboxMenuFooterStyle : String = "display:flex;flex:none;flex-direction:column;padding-right:4px"
///|
const CheckboxMenuFooterRowStyle : String = "display:flex;align-items:center;gap:8px;width:100%;height:36px;max-height:36px;min-height:36px;padding:9px 12px;border:none;border-radius:var(--sumi-radius,8px);background:transparent;font-size:13px;line-height:22px;font-weight:400;color:var(--sumi-text,#ffffff);text-align:left;cursor:pointer;user-select:none;appearance:none;-webkit-appearance:none;outline:none"
///|
/// The hairline between groups (`top=false`, vertically centered) and above
/// the sticky footer (`top=true`, hugging the boundary).
fn checkbox_menu_divider(top? : Bool = false) -> @html.Html {
@html.div(
style=[
CheckboxMenuDividerStyle,
if top {
"align-items:flex-start"
} else {
"align-items:center"
},
],
attrs=@html.Attrs::build()
.data_set("slot", "checkbox-menu-divider")
.role("separator")
.aria_hidden("true"),
[@html.div(style=[CheckboxMenuDividerLineStyle], "")],
)
}
///|
fn checkbox_menu_title_row(group : CheckboxMenuGroup) -> @html.Html {
let clear : Array[@html.Html] = match group.clear_label {
Some(label) =>
[
@html.button(
style=[
@internal.SumiBoxSizing,
@internal.SumiFontSans,
CheckboxMenuClearStyle,
],
type_="button",
on_click?=group.on_clear,
attrs=@html.Attrs::build().data_set("slot", "checkbox-menu-clear"),
label,
),
]
None => []
}
@html.div(
style=[@internal.SumiFontSans, CheckboxMenuTitleStyle],
attrs=@html.Attrs::build().data_set("slot", "checkbox-menu-title"),
[@html.span(style=[], group.title.unwrap_or("")), ..clear],
)
}
///|
fn checkbox_menu_row(
item : CheckboxMenuItem,
on_toggle : @internal.Emit[String]?,
) -> @html.Html {
let row_attrs = @html.Attrs::build()
.data_set("slot", "checkbox-menu-item")
.role("menuitemcheckbox")
.aria_checked(@internal.sumi_bool(item.checked))
if item.disabled {
ignore(row_attrs.aria_disabled("true"))
}
let leading : @html.Html = match item.icon {
Some(icon) => @html.span(style=[CheckboxMenuIconStyle], [icon])
None => @html.nothing
}
let glyph : Array[@html.Html] = if item.checked {
[@primitives.icon_check_fill(size=12)]
} else {
[]
}
let box_border = if item.checked {
"border:1px solid var(--sumi-text,#ffffff)"
} else {
"border:1px solid var(--sumi-text-secondary,rgba(255,255,255,0.8))"
}
// Unchecked rows hide the box until hover/active (the interaction sheet
// flips its opacity); checked rows keep the bordered tick visible.
let check = @html.span(
style=[
CheckboxMenuCheckStyle,
if item.checked {
"opacity:1"
} else {
"opacity:0"
},
],
attrs=@html.Attrs::build()
.data_set("slot", "checkbox-menu-check")
.aria_hidden("true"),
[
@html.span(
style=["position:absolute;inset:10%;border-radius:4px", box_border],
([] : Array[@html.Html]),
),
@html.span(
style=[
"position:absolute;inset:20%;display:flex;align-items:center;justify-content:center;color:var(--sumi-text,#ffffff)",
],
glyph,
),
],
)
@html.button(
style=[
@internal.SumiBoxSizing,
@internal.SumiFontSans,
@internal.SumiTextRendering,
@internal.SumiTransition,
CheckboxMenuItemStyle,
if item.disabled {
@internal.SumiDisabled +
";color:var(--sumi-text-faint,rgba(255,255,255,0.4))"
} else {
""
},
],
type_="button",
disabled=item.disabled,
on_click?=on_toggle.map(emit => emit(item.value)),
attrs=row_attrs,
[leading, @html.span(style=[CheckboxMenuLabelStyle], item.label), check],
)
}
///|
fn checkbox_menu_group_view(
group : CheckboxMenuGroup,
last~ : Bool,
on_toggle : @internal.Emit[String]?,
) -> @html.Html {
let children : Array[@html.Html] = []
if group.title is Some(_) {
children.push(checkbox_menu_title_row(group))
}
for item in group.items {
children.push(checkbox_menu_row(item, on_toggle))
}
if !last {
children.push(checkbox_menu_divider())
}
@html.div(
style=[CheckboxMenuGroupStyle],
attrs=@html.Attrs::build()
.data_set("slot", "checkbox-menu-group")
.role("group"),
children,
)
}
///|
/// A multi-select menu panel: grouped rows with an outline checkbox on the
/// right edge, toggled independently, so picking a row never closes anything.
/// Fully controlled — the caller owns each row's `checked` flag; `on_toggle`
/// emits the `value` of the row that was clicked. Unchecked rows reveal an
/// empty box on hover/press; checked rows always show the ticked box.
///
/// `width` follows the design's size steps (160 / 200 / 240 / 320, default
/// 200); the body scrolls once the content exceeds `max_height` (default
/// 480). `footer_label` pins a plain row (optional `footer_icon`, activated
/// through `on_footer`) below the scroll area, under a hairline.
///
/// The panel is unpositioned: wrap it in a dropdown/popover container to
/// anchor it to a trigger.
///
/// ```moonbit nocheck
/// checkbox_menu(
/// groups=[
/// CheckboxMenuGroup::new(
/// [
/// CheckboxMenuItem::new("text", "Text", checked=true),
/// CheckboxMenuItem::new("date", "Date"),
/// ],
/// title="Fields",
/// clear_label="Clear",
/// on_clear=clear_fields,
/// ),
/// ],
/// on_toggle=toggle_field.map(v => _ => v),
/// )
/// ```
pub fn checkbox_menu(
groups~ : Array[CheckboxMenuGroup],
on_toggle? : @internal.Emit[String],
width? : Int = 200,
max_height? : Int = 480,
footer_label? : String,
footer_icon? : @html.Html,
on_footer? : @cmd.Cmd,
id? : String,
class? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
) -> @html.Html {
let element_attrs = @internal.sumi_attrs(attrs)
.data_set("slot", "checkbox-menu")
.role("menu")
let group_count = groups.length()
let group_views = groups.mapi(fn(i, group) {
checkbox_menu_group_view(group, last=i == group_count - 1, on_toggle)
})
let footer : Array[@html.Html] = match footer_label {
Some(label) => {
let icon_view : @html.Html = match footer_icon {
Some(icon) => @html.span(style=[CheckboxMenuIconStyle], [icon])
None => @html.nothing
}
[
@html.div(
style=[CheckboxMenuFooterStyle],
attrs=@html.Attrs::build().data_set("slot", "checkbox-menu-footer"),
[
checkbox_menu_divider(top=true),
@html.button(
style=[
@internal.SumiBoxSizing,
@internal.SumiFontSans,
@internal.SumiTextRendering,
@internal.SumiTransition,
CheckboxMenuFooterRowStyle,
],
type_="button",
on_click?=on_footer,
attrs=@html.Attrs::build()
.data_set("slot", "checkbox-menu-footer-row")
.role("menuitem"),
[icon_view, @html.span(style=[CheckboxMenuLabelStyle], label)],
),
],
),
]
}
None => []
}
@html.div(
style=@internal.sumi_styles(
[
@internal.SumiBoxSizing,
@internal.SumiFontSans,
@internal.SumiTextRendering,
CheckboxMenuStyle,
"width:\{width}px;max-height:\{max_height}px",
],
style,
),
id?,
class?,
attrs=element_attrs,
[
@html.div(
style=[@internal.SumiBoxSizing, CheckboxMenuBodyStyle],
attrs=@html.Attrs::build().data_set("slot", "checkbox-menu-body"),
group_views,
),
..footer,
],
)
}