///|
/// One entry in a `dropdown_menu`. Rendered as a rich row (thumbnail +
/// title + description + check mark) when `description` is present,
/// otherwise as a compact icon-label row. `shortcut` renders a muted
/// right-aligned key hint (e.g. `"⌘E"`).
pub(all) struct MenuItem {
value : String
label : String
description : String?
icon : @html.Html?
shortcut : String?
disabled : Bool
}
///|
pub fn MenuItem::new(
value : String,
label : String,
description? : String,
icon? : @html.Html,
shortcut? : String,
disabled? : Bool = false,
) -> MenuItem {
{ value, label, description, icon, shortcut, disabled, }
}
///|
/// The content of a menu: a list of selectable items with optional
/// separators and section labels between groups.
pub(all) enum MenuEntry {
Item(MenuItem)
Separator
SectionLabel(String)
}
///|
/// Convenience for the common case — wrap a plain item list.
pub fn MenuEntry::items(items : Array[MenuItem]) -> Array[MenuEntry] {
items.map(fn(item) { Item(item) })
}
///|
pub(all) enum MenuAlign {
Start
End
} derive(Debug, Eq)
///|
pub extend MenuAlign with Eq::{equal, not_equal}
///|
pub extend MenuAlign with Debug::{to_repr}
///|
pub(all) enum MenuSide {
Bottom
Top
} derive(Debug, Eq)
///|
pub extend MenuSide with Eq::{equal, not_equal}
///|
pub extend MenuSide with Debug::{to_repr}
///|
const MenuContainerStyle : String = "position:absolute;z-index:50;display:flex;flex-direction:column;gap:2px;min-width:240px;padding:4px;border:1px solid var(--sumi-stroke-secondary,rgba(255,255,255,0.04));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))"
///|
const MenuItemBaseStyle : String = "display:flex;align-items:center;gap:8px;width:100%;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"
///|
const MenuItemRichStyle : String = "padding:8px 16px 8px 12px;min-height:64px;gap:12px;border-radius:var(--sumi-radius-lg,12px)"
///|
const MenuItemSelectedStyle : String = "background:var(--sumi-block,rgba(255,255,255,0.08))"
///|
const MenuLabelStyle : String = "flex:1 1 0%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
///|
const MenuDescriptionStyle : String = "overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:20px;color:var(--sumi-text-placeholder,rgba(255,255,255,0.35))"
///|
const MenuOverlayStyle : String = "position:fixed;inset:0;z-index:40;background:transparent"
///|
const MenuSeparatorStyle : String = "height:1px;margin:4px 2px;background:var(--sumi-stroke-secondary,rgba(255,255,255,0.04))"
///|
const MenuSectionLabelStyle : String = "padding:6px 12px 2px;font-size:11px;line-height:16px;font-weight:500;color:var(--sumi-text-placeholder,rgba(255,255,255,0.35));user-select:none"
///|
const MenuShortcutStyle : String = "margin-left:auto;padding-left:16px;font-size:12px;line-height:20px;color:var(--sumi-text-placeholder,rgba(255,255,255,0.35))"
///|
fn menu_align_style(align : MenuAlign) -> String {
match align {
Start => "left:0"
End => "right:0"
}
}
///|
fn menu_align_value(align : MenuAlign) -> String {
match align {
Start => "start"
End => "end"
}
}
///|
fn menu_side_style(side : MenuSide) -> String {
match side {
Bottom => "top:calc(100% + 6px)"
Top => "bottom:calc(100% + 6px)"
}
}
///|
fn menu_side_value(side : MenuSide) -> String {
match side {
Bottom => "bottom"
Top => "top"
}
}
///|
fn menu_item_view(
item : MenuItem,
selected : String?,
on_click : @cmd.Cmd?,
) -> @html.Html {
let is_selected = match selected {
Some(value) => value == item.value
None => false
}
let rich = item.description is Some(_)
let item_attrs = @html.Attrs::build().data_set("slot", "menu-item")
if item.disabled {
ignore(item_attrs.aria_disabled("true"))
}
let leading : @html.Html = match item.icon {
Some(icon) =>
if rich {
@html.span(
style=[
"display:inline-flex;flex:none;align-items:center;justify-content:center;width:40px;height:40px;border:1px solid var(--sumi-stroke-secondary,rgba(255,255,255,0.04));border-radius:var(--sumi-radius,8px)",
],
[icon],
)
} else {
@html.span(
style=[
"display:inline-flex;flex:none;align-items:center;justify-content:center;width:20px;height:20px",
],
[icon],
)
}
None => @html.nothing
}
let text_block = if rich {
@html.div(
style=["display:flex;flex:1 1 0%;flex-direction:column;min-width:0"],
[
@html.div(
style=[
@internal.SumiFontNum,
MenuLabelStyle,
"font-size:14px;line-height:22px;font-weight:400;color:var(--sumi-text,#ffffff)",
],
item.label,
),
@html.div(style=[MenuDescriptionStyle], item.description.unwrap_or("")),
],
)
} else {
@html.span(style=[MenuLabelStyle], item.label)
}
let trailing : @html.Html = if is_selected {
@html.span(
style=["display:inline-flex;flex:none;color:var(--sumi-text,#ffffff)"],
[@primitives.icon_check(size=16)],
)
} else {
@html.nothing
}
let shortcut_view : @html.Html = match item.shortcut {
Some(shortcut) =>
@html.span(
style=[@internal.SumiFontNum, MenuShortcutStyle],
attrs=@html.Attrs::build().data_set("slot", "menu-shortcut"),
shortcut,
)
None => @html.nothing
}
@html.button(
style=[
@internal.SumiBoxSizing,
@internal.SumiFontSans,
@internal.SumiTextRendering,
@internal.SumiTransition,
MenuItemBaseStyle,
if rich {
MenuItemRichStyle
} else {
""
},
if is_selected {
MenuItemSelectedStyle
} else {
""
},
if item.disabled {
@internal.SumiDisabled +
";color:var(--sumi-text-disabled,rgba(255,255,255,0.2))"
} else {
""
},
],
type_="button",
disabled=item.disabled,
on_click?,
attrs=item_attrs,
[leading, text_block, shortcut_view, trailing],
)
}
///|
fn menu_entry_view(
entry : MenuEntry,
selected : String?,
click_for : (MenuItem) -> @cmd.Cmd?,
) -> @html.Html {
match entry {
Item(item) => menu_item_view(item, selected, click_for(item))
Separator =>
@html.div(
style=[MenuSeparatorStyle],
attrs=@html.Attrs::build()
.data_set("slot", "menu-separator")
.role("separator")
.aria_hidden("true"),
"",
)
SectionLabel(text) =>
@html.div(
style=[@internal.SumiFontSans, MenuSectionLabelStyle],
attrs=@html.Attrs::build().data_set("slot", "menu-section-label"),
text,
)
}
}
///|
/// The chevron for menu triggers. It carries the `menu-chevron` slot, so the
/// interaction sheet rotates it 180° while the parent dropdown is open.
pub fn menu_chevron(size? : Int = 12) -> @html.Html {
@html.span(
style=[
"display:inline-flex;flex:none;align-items:center;justify-content:center",
],
attrs=@html.Attrs::build()
.data_set("slot", "menu-chevron")
.aria_hidden("true"),
[@primitives.icon_chevron_down(size~)],
)
}
///|
/// A dropdown menu with a caller-supplied trigger. Fully controlled: the
/// caller owns both the open flag (`open` + `on_open_change`) and the
/// selected value (`selected` + `on_select`). Closes on outside click and
/// Escape; when open, Arrow/Home/End keys move focus between items and
/// Enter activates the focused item (handled by the theme's static event
/// layer, so callers wire nothing extra).
///
/// `side=Top` opens the panel above the trigger (input docks at the bottom
/// of the page); the enter animation's transform origin follows `side` and
/// `align`. `menu_style` appends overrides to the panel (e.g. a width).
///
/// ```moonbit nocheck
/// dropdown_menu(
/// trigger=button(variant=Ghost, [text(model_label), menu_chevron()]),
/// open=menu_open,
/// on_open_change=set_menu_open.map(v => _ => v),
/// items=MenuEntry::items([MenuItem::new("sd", "Sketch Diffusion")]),
/// selected=model,
/// on_select=set_model.map(v => _ => v),
/// )
/// ```
pub fn dropdown_menu(
items~ : Array[MenuEntry],
trigger~ : @html.Html,
open~ : Bool,
on_open_change? : @internal.Emit[Bool],
selected? : String,
on_select? : @internal.Emit[String],
align? : MenuAlign = Start,
side? : MenuSide = Bottom,
id? : String,
class? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
trigger_style? : Array[String] = [],
menu_style? : Array[String] = [],
) -> @html.Html {
let close : @cmd.Cmd? = on_open_change.map(emit => emit(false))
let toggle : @cmd.Cmd? = on_open_change.map(emit => emit(!open))
let menu : @html.Html = {
// The overlay and panel stay mounted and toggle `hidden`, so the
// `display ... allow-discrete` transition in the interaction sheet can
// animate both the enter and the exit instead of cutting off abruptly.
let overlay : @html.Html = match close {
Some(cmd) =>
@html.div(
style=[MenuOverlayStyle],
hidden=!open,
on_click=cmd,
attrs=@html.Attrs::build()
.data_set("slot", "menu-overlay")
.aria_hidden("true"),
"",
)
None => @html.nothing
}
let click_for = fn(item : MenuItem) -> @cmd.Cmd? {
if item.disabled {
None
} else {
let select_cmd = on_select.map(emit => emit(item.value))
match (close, select_cmd) {
(Some(c), Some(s)) => Some(@cmd.batch([c, s]))
(Some(c), None) => Some(c)
(None, Some(s)) => Some(s)
(None, None) => None
}
}
}
let item_views = items.map(entry => {
menu_entry_view(entry, selected, click_for)
})
@html.fragment([
overlay,
@html.div(
style=@internal.sumi_styles(
[
@internal.SumiBoxSizing,
@internal.SumiFontSans,
@internal.SumiTextRendering,
MenuContainerStyle,
menu_align_style(align),
menu_side_style(side),
],
menu_style,
),
hidden=!open,
attrs=@html.Attrs::build()
.data_set("slot", "menu")
.data_set("side", menu_side_value(side))
.data_set("align", menu_align_value(align))
.role("menu"),
item_views,
),
])
}
let element_attrs = @internal.sumi_attrs(attrs)
.data_set("slot", "dropdown")
.data_set("state", if open { "open" } else { "closed" })
let trigger_attrs = @html.Attrs::build().data_set("slot", "dropdown-trigger")
if toggle is Some(cmd) {
ignore(trigger_attrs.on_click(_ => cmd))
}
@html.div(
style=@internal.sumi_styles(
[@internal.SumiBoxSizing, "position:relative;display:inline-flex"],
style,
),
id?,
class?,
attrs=element_attrs,
[
@html.div(
style=@internal.sumi_styles(["display:inline-flex"], trigger_style),
attrs=trigger_attrs,
[trigger],
),
menu,
],
)
}