///|
struct MenuCoreModel {
open : Bool
active : Int
checked : Array[String]
radios : Array[(String, String)]
submenus : Array[String]
} derive(Eq)
///|
enum MenuSubmenuChange {
MenuOpenSubmenu(String)
MenuSwitchSubmenu(String, String)
MenuCloseSubmenu(String)
MenuCloseSubmenusUnder(String)
MenuCloseAllSubmenus
} derive(Eq)
///|
priv struct MenuScope {
id : String
content_id : String
trigger_id : String
model : MenuCoreModel
counter : Ref[Int]
close_command : @cmd.Cmd
toggle_command : @cmd.Cmd
toggle_checked : @cmd.Emit[String]
set_radio : @cmd.Emit[(String, String)]
}
///|
enum MenuIndicatorPlacement {
MenuIndicatorInlineStart
MenuIndicatorInlineEnd
} derive(Eq)
///|
const MenuRootStyle : String = "display:contents"
///|
const MenuContentStyle : String = "position:fixed;inset:auto;left:var(--rui-floating-left,auto);top:var(--rui-floating-top,auto);z-index:50;display:flex;width:max-content;min-width:min(var(--rui-menu-min-width,12rem),calc(100vw - 1rem));max-width:calc(100vw - 1rem);max-height:var(--rui-menu-available-height,calc(100vh - 1rem));transform-origin:var(--rui-floating-transform-origin,center);flex-direction:column;gap:0.125rem;overflow-x:hidden;overflow-y:auto;margin:0;border:1px solid color-mix(in oklab,var(--rui-foreground,oklch(0.145 0 0)) 10%,transparent);border-radius:calc(var(--rui-radius,0.625rem) - 0.125rem);background:var(--rui-popover,oklch(1 0 0));padding:0.25rem;color:var(--rui-popover-foreground,oklch(0.145 0 0));font-size:0.875rem;line-height:1.25rem;box-shadow:0 10px 15px -3px rgb(0 0 0 / 0.1),0 4px 6px -4px rgb(0 0 0 / 0.1);outline:none;transition:opacity 100ms ease,transform 100ms ease,visibility 100ms ease"
///|
const MenuSubContentStyle : String = "position:fixed;inset:auto;left:var(--rui-submenu-left,auto);top:var(--rui-submenu-top,auto);z-index:51;display:flex;width:max-content;min-width:min(var(--rui-menu-min-width,12rem),calc(100vw - 1rem));max-width:min(18rem,calc(100vw - 1rem));max-height:calc(100vh - 1rem);transform-origin:var(--rui-floating-transform-origin,0% 0%);flex-direction:column;gap:0.125rem;overflow-x:hidden;overflow-y:auto;border:1px solid color-mix(in oklab,var(--rui-foreground,oklch(0.145 0 0)) 10%,transparent);border-radius:calc(var(--rui-radius,0.625rem) - 0.125rem);background:var(--rui-popover,oklch(1 0 0));padding:0.25rem;color:var(--rui-popover-foreground,oklch(0.145 0 0));box-shadow:0 10px 15px -3px rgb(0 0 0 / 0.1),0 4px 6px -4px rgb(0 0 0 / 0.1);outline:none;transition:opacity 100ms ease,transform 100ms ease,visibility 100ms ease"
///|
const MenuItemStyle : String = "position:relative;display:flex;width:100%;min-height:2rem;cursor:default;align-items:center;gap:0.5rem;border:0;border-radius:calc(var(--rui-radius,0.625rem) - 0.25rem);background:var(--rui-menu-item-bg,var(--rui-menu-item-base-bg,transparent));padding:0.375rem 0.5rem;color:var(--rui-menu-item-fg,var(--rui-menu-item-base-fg,inherit));font:inherit;line-height:1.25rem;text-align:start;white-space:nowrap;outline:none;user-select:none"
///|
const MenuItemInsetStyle : String = "padding-inline-start:2rem"
///|
const MenuItemDestructiveStyle : String = "--rui-menu-item-base-fg:var(--rui-destructive,oklch(0.577 0.245 27.325))"
///|
const MenuItemHighlightedStyle : String = "--rui-menu-item-base-bg:var(--rui-accent,oklch(0.97 0 0));--rui-menu-item-base-fg:var(--rui-accent-foreground,oklch(0.205 0 0))"
///|
const MenuLabelStyle : String = "padding:0.375rem 0.5rem;font-size:0.75rem;line-height:1rem;font-weight:500;color:var(--rui-muted-foreground,oklch(0.556 0 0))"
///|
const MenuSeparatorStyle : String = "height:1px;margin:0.25rem -0.25rem;background:var(--rui-border,oklch(0.922 0 0));pointer-events:none"
///|
const MenuShortcutStyle : String = "margin-inline-start:auto;flex:none;padding-inline-start:1.5rem;color:var(--rui-muted-foreground,oklch(0.556 0 0));font-size:0.75rem;line-height:1rem;letter-spacing:0.08em"
///|
const MenuItemEndIndicatorStyle : String = "padding-inline-end:2rem"
///|
const MenuIndicatorStartStyle : String = "display:inline-flex;width:1rem;height:1rem;flex:none;align-items:center;justify-content:center;font-size:0.75rem;line-height:1"
///|
const MenuIndicatorEndStyle : String = "position:absolute;inset-inline-end:0.5rem;display:inline-flex;width:1rem;height:1rem;align-items:center;justify-content:center;font-size:0.75rem;line-height:1;pointer-events:none"
///|
fn menu_core_model(
open : Bool,
checked : Array[String],
radios : Array[(String, String)],
) -> MenuCoreModel {
{ open, active: -1, checked, radios, submenus: [] }
}
///|
fn menu_contains(values : Array[String], value : String) -> Bool {
for current in values {
if current == value {
return true
}
}
false
}
///|
fn menu_toggle_value(values : Array[String], value : String) -> Array[String] {
let next : Array[String] = []
let mut found = false
for current in values {
if current == value {
found = true
} else {
next.push(current)
}
}
if !found {
next.push(value)
}
next
}
///|
fn menu_submenu_open(values : Array[String], value : String) -> Bool {
menu_contains(values, value)
}
///|
fn menu_update_submenus(
values : Array[String],
change : MenuSubmenuChange,
) -> Array[String] {
match change {
MenuOpenSubmenu(value) =>
if menu_contains(values, value) {
values
} else {
let next = values.copy()
next.push(value)
next
}
MenuSwitchSubmenu(parent, value) =>
if parent == "" {
if values == [value] {
values
} else {
[value]
}
} else {
let next : Array[String] = []
for current in values {
next.push(current)
if current == parent {
next.push(value)
return if next == values { values } else { next }
}
}
values
}
MenuCloseSubmenu(value) => {
let next : Array[String] = []
for current in values {
if current == value {
return next
}
next.push(current)
}
values
}
MenuCloseSubmenusUnder(parent) =>
if parent == "" {
[]
} else {
let next : Array[String] = []
for current in values {
next.push(current)
if current == parent {
return if next == values { values } else { next }
}
}
values
}
MenuCloseAllSubmenus => []
}
}
///|
fn menu_submenu_key(scope : MenuScope, value : String) -> String {
scope.id + "::" + value + "::submenu-\{scope.counter.val}"
}
///|
fn menu_submenu_anchor_style(key : String) -> String {
"position-anchor:\{popup_anchor_name(key)};position-area:inline-end span-block-end;position-try-fallbacks:flip-inline,flip-block,flip-inline flip-block;position-visibility:always;margin-inline-start:0.25rem;--rui-floating-transform-origin:0% 0%"
}
///|
fn menu_submenu_trigger_id(key : String) -> String {
popup_anchor_name(key) + "-trigger"
}
///|
fn menu_submenu_content_id(key : String) -> String {
popup_anchor_name(key) + "-content"
}
///|
fn menu_radio_value(
radios : Array[(String, String)],
group : String,
) -> String? {
for entry in radios {
if entry.0 == group {
return Some(entry.1)
}
}
None
}
///|
fn menu_set_radio_value(
radios : Array[(String, String)],
group : String,
value : String,
) -> Array[(String, String)] {
let next : Array[(String, String)] = []
let mut replaced = false
for entry in radios {
if entry.0 == group {
if !replaced {
next.push((group, value))
replaced = true
}
} else {
next.push(entry)
}
}
if !replaced {
next.push((group, value))
}
next
}
///|
fn menu_scope_next_index(scope : MenuScope) -> Int {
let index = scope.counter.val
scope.counter.val += 1
index
}
///|
#cfg(not(target="js"))
fn[A] menu_noop_emit() -> @cmd.Emit[A] {
@cmd.Emit(_ => @cmd.none)
}
///|
fn menu_state_attrs(
attrs : @html.Attrs?,
slot : String,
open : Bool,
) -> @html.Attrs {
popup_state_attrs(attrs, slot, open)
}
///|
#cfg(target="js")
priv struct MenuSurfaceBinding {
id : String
root : @dom.Element
dispatch : Ref[(Int, Int, String, String) -> Unit]
typeahead : Ref[String]
typeahead_at : Ref[Double]
typeahead_timer : Ref[Int]
}
///|
#cfg(target="js")
let menu_surface_bindings : Map[String, MenuSurfaceBinding] = Map([])
///|
#cfg(target="js")
let menu_pending_edges : Map[String, String] = Map([])
///|
#cfg(target="js")
fn menu_request_frame(callback : () -> Unit) -> Unit {
ui_after_request_frame(callback)
}
///|
#cfg(target="js")
fn menu_element_is_visible(element : @dom.Element) -> Bool {
if !ui_element_is_enabled(element) ||
element.closest("[hidden]") is Some(_) ||
element.closest("[inert]") is Some(_) ||
element.closest("[aria-hidden=\"true\"]") is Some(_) ||
element.get_client_rects().length() == 0 {
return false
}
if ui_has_window() {
let style = @dom.window().get_computed_style(element)
style.get_property_value("display") != "none" &&
style.get_property_value("visibility") != "hidden"
} else {
true
}
}
///|
#cfg(target="js")
fn menu_enabled_items(
binding : MenuSurfaceBinding,
container : @dom.Element?,
) -> Array[@dom.Element] {
let scope = container.unwrap_or(binding.root)
scope
.query_selector_all("[data-menu-index]")
.filter(item => {
if !menu_element_is_visible(item) {
false
} else if container is Some(container) {
item.closest("[role=\"menu\"]") is Some(owner) &&
owner.is_same_node(container.as_node())
} else {
true
}
})
}
///|
#cfg(target="js")
fn menu_send_active(binding : MenuSurfaceBinding, item : @dom.Element?) -> Unit {
if item is Some(item) && ui_element_is_enabled(item) {
let index = ui_parse_int_or(
item.get_attribute("data-menu-index").unwrap_or(""),
-1,
)
if index >= 0 {
(binding.dispatch.val)(0, index, "", "")
}
}
}
///|
#cfg(target="js")
fn menu_submenu_parent(item : @dom.Element) -> String {
if item.closest("[role=\"menu\"]") is Some(owner) {
owner.get_attribute("data-submenu-owner").unwrap_or("")
} else {
""
}
}
///|
#cfg(target="js")
fn menu_focus_submenu_trigger(
binding : MenuSurfaceBinding,
key : String,
) -> Unit {
menu_request_frame(() => {
let target = ui_find_element(
binding.root.query_selector_all("[data-menu-index][data-submenu-key]"),
node => {
node.get_attribute("data-submenu-key").unwrap_or("") == key &&
menu_element_is_visible(node)
},
)
if target is Some(target) {
ui_focus_element(target)
menu_send_active(binding, Some(target))
}
})
}
///|
#cfg(target="js")
fn menu_focus_submenu_first(
binding : MenuSurfaceBinding,
key : String,
attempt : Int,
) -> Unit {
menu_request_frame(() => {
let owner = ui_find_element(
binding.root.query_selector_all("[data-submenu-owner]"),
node => {
node.get_attribute("data-submenu-owner").unwrap_or("") == key &&
menu_element_is_visible(node)
},
)
if owner is Some(owner) {
if menu_enabled_items(binding, Some(owner)).get(0) is Some(target) {
ui_focus_element(target)
menu_send_active(binding, Some(target))
} else if attempt < 20 {
menu_focus_submenu_first(binding, key, attempt + 1)
}
} else if attempt < 20 {
menu_focus_submenu_first(binding, key, attempt + 1)
}
})
}
///|
#cfg(target="js")
fn menu_switch_submenu(
binding : MenuSurfaceBinding,
item : @dom.Element?,
focus_first : Bool,
) -> Bool {
guard item is Some(item) else { return false }
let key = item.get_attribute("data-submenu-key").unwrap_or("")
if key == "" || !ui_element_is_enabled(item) {
return false
}
let index = ui_parse_int_or(
item.get_attribute("data-menu-index").unwrap_or(""),
-1,
)
(binding.dispatch.val)(4, index, key, menu_submenu_parent(item))
if focus_first {
menu_focus_submenu_first(binding, key, 0)
}
true
}
///|
#cfg(target="js")
fn menu_focus_dropdown_edge(
binding : MenuSurfaceBinding,
content_id : String,
edge : String,
attempt : Int,
) -> Unit {
menu_request_frame(() => {
guard ui_element_by_id(binding.id) is Some(live_root) else { return }
let content = ui_element_by_id(content_id)
let items = menu_enabled_items(binding, content)
if live_root.get_attribute("data-state").unwrap_or("") != "open" ||
items.length() == 0 {
if attempt < 20 {
menu_focus_dropdown_edge(binding, content_id, edge, attempt + 1)
}
return
}
let requested_edge = menu_pending_edges.get(binding.id).unwrap_or(edge)
let target = if requested_edge == "last" {
items.get(items.length() - 1)
} else {
items.get(0)
}
if target is Some(target) {
ui_focus_element(target)
menu_send_active(binding, Some(target))
if ui_has_window() {
ignore(
@dom.window().set_timeout(
() => {
if menu_pending_edges.get(binding.id) == Some(edge) {
menu_pending_edges.remove(binding.id)
}
},
250,
),
)
}
}
})
}
///|
#cfg(target="js")
fn menu_typeahead_query(buffer : String) -> String {
guard buffer.get_char(0) is Some(first) else { return "" }
let mut repeated = true
for character in buffer {
if character != first {
repeated = false
}
}
if repeated {
"\{first}"
} else {
buffer
}
}
///|
#cfg(target="js")
fn menu_typeahead_label(item : @dom.Element) -> String {
let labelled = item.get_attribute("data-menu-label").unwrap_or("")
if labelled != "" {
labelled
} else {
item.get_property("textContent").to_option().unwrap_or("")
}
}
///|
#cfg(target="js")
fn menu_handle_keydown(
binding : MenuSurfaceBinding,
event : @dom.KeyboardEvent,
) -> Unit {
if event.get_default_prevented() {
return
}
guard event.target().to_element() is Some(event_target) else { return }
let key = event.key()
let dropdown_trigger = event_target.closest(
"[data-slot=\"dropdown-menu-trigger\"]",
)
if dropdown_trigger is Some(dropdown_trigger) &&
(key == "ArrowDown" || key == "ArrowUp") {
event.prevent_default()
let edge = if key == "ArrowUp" { "last" } else { "first" }
let content_id = dropdown_trigger
.get_attribute("aria-controls")
.unwrap_or("")
menu_pending_edges[binding.id] = edge
if ui_has_window() {
ignore(
@dom.window().set_timeout(
() => {
if menu_pending_edges.get(binding.id) == Some(edge) {
menu_pending_edges.remove(binding.id)
}
},
1000,
),
)
}
if binding.root.get_attribute("data-state").unwrap_or("") != "open" {
ui_click_element(dropdown_trigger)
}
menu_focus_dropdown_edge(binding, content_id, edge, 0)
return
}
let current = event_target.closest("[data-menu-index]")
let container = if current is Some(current) {
current.closest("[role=\"menu\"]").unwrap_or(binding.root)
} else {
binding.root
}
let items = menu_enabled_items(binding, Some(container))
let active_submenu = event_target.closest("[data-submenu-owner]")
let rtl = ui_element_is_rtl(container)
let open_submenu_key = if rtl { "ArrowLeft" } else { "ArrowRight" }
let close_submenu_key = if rtl { "ArrowRight" } else { "ArrowLeft" }
if key == "Escape" && active_submenu is Some(active_submenu) {
event.prevent_default()
event.stop_propagation()
let submenu_key = active_submenu
.get_attribute("data-submenu-owner")
.unwrap_or("")
(binding.dispatch.val)(3, -1, submenu_key, "")
menu_focus_submenu_trigger(binding, submenu_key)
return
}
if key == "Escape" {
event.prevent_default()
(binding.dispatch.val)(1, -1, "", "")
return
}
if items.length() == 0 {
return
}
let mut position = -1
if current is Some(current) {
for item_index, item in items {
if item.is_same_node(current.as_node()) {
position = item_index
}
}
}
let mut target : @dom.Element? = None
if key == "ArrowDown" {
target = items.get(
if position < 0 {
0
} else {
(position + 1) % items.length()
},
)
} else if key == "ArrowUp" {
target = items.get(
if position < 0 {
items.length() - 1
} else if position == 0 {
items.length() - 1
} else {
position - 1
},
)
} else if key == "Home" {
target = items.get(0)
} else if key == "End" {
target = items.get(items.length() - 1)
}
if current is Some(current) &&
current.get_attribute("data-submenu-key").unwrap_or("") != "" &&
(key == open_submenu_key || key == "Enter" || key == " ") {
event.prevent_default()
ignore(menu_switch_submenu(binding, Some(current), true))
return
}
if key == close_submenu_key && active_submenu is Some(active_submenu) {
event.prevent_default()
event.stop_propagation()
let submenu_key = active_submenu
.get_attribute("data-submenu-owner")
.unwrap_or("")
(binding.dispatch.val)(3, -1, submenu_key, "")
menu_focus_submenu_trigger(binding, submenu_key)
return
}
if target is None &&
key.char_length_eq(1) &&
!event.meta_key() &&
!event.ctrl_key() &&
!event.alt_key() {
if key == " " && binding.typeahead.val == "" {
return
}
let now = event.get_time_stamp()
if binding.typeahead_at.val <= 0.0 ||
now - binding.typeahead_at.val > 1000.0 {
binding.typeahead.val = ""
}
binding.typeahead_at.val = now
binding.typeahead.val = binding.typeahead.val + key.to_lower()
if ui_has_window() {
if binding.typeahead_timer.val != 0 {
@dom.window().clear_timeout(binding.typeahead_timer.val)
}
binding.typeahead_timer.val = @dom.window().set_timeout(
() => {
binding.typeahead.val = ""
binding.typeahead_at.val = 0.0
binding.typeahead_timer.val = 0
},
1000,
)
}
let query = menu_typeahead_query(binding.typeahead.val)
let ordered : Array[@dom.Element] = []
if position < 0 {
for item in items {
ordered.push(item)
}
} else {
for offset in 1..<=items.length() {
if items.get((position + offset) % items.length()) is Some(item) {
ordered.push(item)
}
}
}
target = ui_find_element(ordered, item => {
menu_typeahead_label(item).trim().to_owned().to_lower().has_prefix(query)
})
event.prevent_default()
}
if target is Some(target) {
event.prevent_default()
ui_focus_element(target)
menu_send_active(binding, Some(target))
}
}
///|
#cfg(target="js")
fn bind_menu_surface(
id : String,
dispatch : (Int, Int, String, String) -> Unit,
) -> Unit {
guard ui_element_by_id(id) is Some(root) else { return }
if menu_surface_bindings.get(id) is Some(existing) &&
existing.root.is_same_node(root.as_node()) {
existing.dispatch.val = dispatch
return
}
let binding : MenuSurfaceBinding = {
id,
root,
dispatch: Ref(dispatch),
typeahead: Ref(""),
typeahead_at: Ref(0.0),
typeahead_timer: Ref(0),
}
menu_surface_bindings[id] = binding
root.add_event_listener("focusin", event => {
if event.target().to_element() is Some(target) {
menu_send_active(binding, target.closest("[data-menu-index]"))
}
})
root.add_event_listener("pointermove", event => {
guard event.target().to_element() is Some(target) else { return }
let item = target.closest("[data-menu-index]")
menu_send_active(binding, item)
if item is Some(item) {
if item.get_attribute("data-submenu-key").unwrap_or("") != "" &&
ui_element_is_enabled(item) {
ignore(menu_switch_submenu(binding, Some(item), false))
} else {
(binding.dispatch.val)(
5,
ui_parse_int_or(
item.get_attribute("data-menu-index").unwrap_or(""),
-1,
),
"",
menu_submenu_parent(item),
)
}
}
})
root.add_event_listener_with_options(
"click",
event => {
if event.target().to_element() is Some(target) {
ignore(
menu_switch_submenu(
binding,
target.closest("[data-menu-index][data-submenu-key]"),
false,
),
)
}
},
capture=true,
)
root.add_event_listener("keydown", event => {
if event.to_keyboard_event() is Some(keyboard_event) {
menu_handle_keydown(binding, keyboard_event)
}
})
}
///|
#cfg(target="js")
priv struct MenuFocusStability {
item : @dom.Element?
frames : Int
}
///|
#cfg(target="js")
let menu_focus_stability : Map[String, MenuFocusStability] = Map([])
///|
#cfg(target="js")
fn menu_resolve_focus_item(
id : String,
content_id : String,
index : Int,
) -> @dom.Element? {
guard ui_element_by_id(id) is Some(root) else { return None }
let content = if content_id == "" {
None
} else {
ui_element_by_id(content_id)
}
if content_id != "" && content is None {
return None
}
let scope = content.unwrap_or(root)
let candidates = scope
.query_selector_all("[data-menu-index=\"\{index}\"]")
.filter(menu_element_is_visible)
if content is Some(content) {
ui_find_element(candidates, item => {
if item.closest("[role=\"menu\"]") is Some(owner) {
owner.is_same_node(content.as_node()) ||
content.contains(owner.as_node())
} else {
false
}
})
} else {
let open_item = ui_find_element(candidates, item => {
if item.closest("[role=\"menu\"]") is Some(owner) {
owner.get_attribute("data-state").unwrap_or("") == "open"
} else {
false
}
})
if open_item is Some(_) {
open_item
} else {
candidates.get(0)
}
}
}
///|
#cfg(target="js")
fn focus_menu_item(id : String, content_id : String, index : Int) -> Bool {
let stability_key = id + ":" + content_id + ":\{index}"
guard menu_resolve_focus_item(id, content_id, index) is Some(item) &&
item.get_is_connected() else {
menu_focus_stability.remove(stability_key)
return false
}
let document = @dom.document()
if document.get_active_element() is None ||
!document.get_active_element().unwrap().is_same_node(item.as_node()) {
ui_focus_element_without_scroll(item)
}
let live_item = menu_resolve_focus_item(id, content_id, index)
let stable = live_item is Some(live_item) &&
live_item.is_same_node(item.as_node()) &&
item.get_is_connected() &&
document.get_active_element() is Some(active) &&
active.is_same_node(item.as_node())
if !stable {
menu_focus_stability[stability_key] = { item: live_item, frames: 0 }
return false
}
let frames = if menu_focus_stability.get(stability_key) is Some(previous) &&
previous.item is Some(previous_item) &&
previous_item.is_same_node(item.as_node()) {
previous.frames + 1
} else {
1
}
if frames < 3 {
menu_focus_stability[stability_key] = { item: Some(item), frames }
false
} else {
menu_focus_stability.remove(stability_key)
true
}
}
///|
#cfg(target="js")
fn focus_first_menu_item(id : String, found : (Int) -> Unit) -> Unit {
fn focus_when_visible(attempt : Int) -> Unit {
guard ui_element_by_id(id) is Some(root) else { return }
let binding = if menu_surface_bindings.get(id) is Some(binding) &&
binding.root.is_same_node(root.as_node()) {
binding
} else {
{
id,
root,
dispatch: Ref((_, _, _, _) => ()),
typeahead: Ref(""),
typeahead_at: Ref(0.0),
typeahead_timer: Ref(0),
}
}
let items = menu_enabled_items(binding, None)
if root.get_attribute("data-state").unwrap_or("") == "open" &&
items.length() > 0 {
let item = if menu_pending_edges.get(id) == Some("last") {
items.get(items.length() - 1)
} else {
items.get(0)
}
menu_pending_edges.remove(id)
if item is Some(item) {
let index = ui_parse_int_or(
item.get_attribute("data-menu-index").unwrap_or(""),
-1,
)
ui_focus_element(item)
if index >= 0 {
found(index)
}
}
} else if attempt < 20 {
menu_request_frame(() => focus_when_visible(attempt + 1))
}
}
menu_request_frame(() => focus_when_visible(0))
}
///|
#cfg(target="js")
fn find_open_menu_content(
id : String,
slot : String,
found : (String, Int) -> Unit,
) -> Unit {
guard ui_element_by_id(id) is Some(root) else { return }
guard ui_find_descendant(root, element => {
element.get_attribute("data-slot").unwrap_or("") == slot &&
element.get_attribute("data-state").unwrap_or("") == "open"
})
is Some(content) else {
return
}
let content_id = content.get_attribute("id").unwrap_or("")
if content_id == "" {
return
}
let menubar_index = content.get_attribute("data-menubar-index").unwrap_or("")
let navigation_index = content
.get_attribute("data-navigation-index")
.unwrap_or("")
let index = if menubar_index != "" {
ui_parse_int_or(menubar_index, -1)
} else {
ui_parse_int_or(navigation_index, -1)
}
found(content_id, index)
}
///|
#cfg(target="js")
let menu_submenu_roots : Map[String, @dom.Element] = Map([])
///|
#cfg(target="js")
let menu_submenu_events_bound : Ref[Bool] = Ref(false)
///|
#cfg(target="js")
let menu_submenu_frame : Ref[Double] = Ref(0.0)
///|
#cfg(target="js")
fn menu_position_submenu_root(root : @dom.Element) -> Unit {
if !root.get_is_connected() {
return
}
let position_area = "inline-end span-block-end"
let css_anchored = @dom.CSS::supports("anchor-name", "--rui-anchor") &&
@dom.CSS::supports("position-anchor", "--rui-anchor") &&
@dom.CSS::supports("position-area", position_area) &&
@dom.CSS::supports("position-try-fallbacks", "flip-inline")
let triggers = root.query_selector_all("[data-menu-index][data-submenu-key]")
for content in root.query_selector_all("[data-submenu-owner]") {
let key = content.get_attribute("data-submenu-owner").unwrap_or("")
let requested_open = content.get_attribute("data-state").unwrap_or("") ==
"open"
// The browser owns the top-layer state. A parent popover can implicitly
// close a submenu without a Rabbita render, so a cached open bit must not
// override the actual :popover-open state on a later reopen.
let native_open = content.matches(":popover-open")
if !requested_open && !native_open {
content.set_attribute("data-positioned", "false")
continue
}
let trigger = ui_find_element(triggers, item => {
item.get_attribute("data-submenu-key").unwrap_or("") == key
})
if trigger is None || !trigger.unwrap().get_is_connected() {
if native_open && content.to_html_element() is Some(html) {
html.hide_popover()
}
content.set_attribute("data-positioned", "false")
continue
}
let trigger = trigger.unwrap()
guard content.to_html_element() is Some(content_html) else { continue }
let style = content_html.get_style()
if css_anchored {
ignore(style.remove_property("--rui-submenu-left"))
ignore(style.remove_property("--rui-submenu-top"))
ignore(style.remove_property("margin"))
content.set_attribute("data-positioning", "css-anchor")
content.set_attribute("data-positioned", "true")
} else {
let anchor = trigger.get_bounding_client_rect()
let measured = content.get_bounding_client_rect()
let measured_width = content_html.get_offset_width()
let measured_height = content_html.get_offset_height()
let floating_width = if measured_width > 0.0 {
measured_width
} else {
measured.get_width()
}
let floating_height = if measured_height > 0.0 {
measured_height
} else {
measured.get_height()
}
let viewport = if ui_has_window() {
@dom.window().get_visual_viewport()
} else {
None
}
let viewport_left = viewport
.map(viewport => viewport.get_offset_left())
.unwrap_or(0.0)
let viewport_top = viewport
.map(viewport => viewport.get_offset_top())
.unwrap_or(0.0)
let document_root = @dom.document().get_document_element()
let viewport_width = viewport
.map(viewport => viewport.get_width())
.unwrap_or_else(() => {
document_root.map(root => root.get_client_width()).unwrap_or(0.0)
})
let viewport_height = viewport
.map(viewport => viewport.get_height())
.unwrap_or_else(() => {
document_root.map(root => root.get_client_height()).unwrap_or(0.0)
})
let clip_left = viewport_left + 8.0
let clip_top = viewport_top + 8.0
let clip_right = viewport_left + viewport_width - 8.0
let clip_bottom = viewport_top + viewport_height - 8.0
let rtl = ui_element_is_rtl(trigger)
let inline_end_side = if rtl { "left" } else { "right" }
let inline_end_left = if rtl {
anchor.get_left() - floating_width - 4.0
} else {
anchor.get_right() + 4.0
}
let inline_start_side = if rtl { "right" } else { "left" }
let inline_start_left = if rtl {
anchor.get_right() + 4.0
} else {
anchor.get_left() - floating_width - 4.0
}
fn overflow(left : Double) -> Double {
(clip_left - left).max(0.0) +
(left + floating_width - clip_right).max(0.0)
}
let use_inline_end = overflow(inline_end_left) <= 0.0 ||
overflow(inline_end_left) <= overflow(inline_start_left)
let placed_side = if use_inline_end {
inline_end_side
} else {
inline_start_side
}
let placed_left = if use_inline_end {
inline_end_left
} else {
inline_start_left
}
let left = placed_left
.max(clip_left)
.min((clip_right - floating_width).max(clip_left))
let top = anchor
.get_top()
.max(clip_top)
.min((clip_bottom - floating_height).max(clip_top))
style.set_property("margin", "0")
style.set_property("--rui-submenu-left", "\{left.round().to_int()}px")
style.set_property("--rui-submenu-top", "\{top.round().to_int()}px")
style.set_property(
"--rui-floating-transform-origin",
if placed_side == "right" {
"0% 0%"
} else {
"100% 0%"
},
)
content.set_attribute("data-side", placed_side)
content.set_attribute("data-positioning", "javascript-fallback")
content.set_attribute("data-positioned", "true")
}
// Coordinates are established before the top-layer state changes so no
// frame can expose the submenu at an unpositioned fixed origin.
if requested_open && !native_open {
content_html.show_popover()
if !content.matches(":popover-open") {
content.set_attribute("data-positioned", "false")
}
} else if !requested_open && native_open {
content_html.hide_popover()
}
if !requested_open {
content.set_attribute("data-positioned", "false")
}
}
}
///|
#cfg(target="js")
fn menu_run_submenu_positions() -> Unit {
menu_submenu_frame.val = 0.0
let stale : Array[String] = []
for id, registered_root in menu_submenu_roots {
if ui_element_by_id(id) is Some(root) &&
root.is_same_node(registered_root.as_node()) &&
root.get_is_connected() {
menu_position_submenu_root(root)
} else {
stale.push(id)
}
}
for id in stale {
menu_submenu_roots.remove(id)
}
}
///|
#cfg(target="js")
fn menu_schedule_submenu_positions() -> Unit {
if menu_submenu_frame.val != 0.0 {
return
}
if ui_has_window() {
menu_submenu_frame.val = @dom.window().request_animation_frame(_ => {
menu_run_submenu_positions()
})
} else {
menu_run_submenu_positions()
}
}
///|
#cfg(target="js")
fn menu_bind_submenu_position_events() -> Unit {
if menu_submenu_events_bound.val || !ui_has_window() {
return
}
menu_submenu_events_bound.val = true
let window = @dom.window()
window.add_event_listener_with_options(
"scroll",
_ => menu_schedule_submenu_positions(),
capture=true,
passive=true,
)
window.add_event_listener_with_options(
"resize",
_ => menu_schedule_submenu_positions(),
passive=true,
)
if window.get_visual_viewport() is Some(viewport) {
viewport.add_event_listener_with_options(
"scroll",
_ => menu_schedule_submenu_positions(),
passive=true,
)
viewport.add_event_listener_with_options(
"resize",
_ => menu_schedule_submenu_positions(),
passive=true,
)
}
}
///|
#cfg(target="js")
fn position_menu_submenus(id : String) -> Unit {
guard ui_element_by_id(id) is Some(root) else {
menu_submenu_roots.remove(id)
return
}
menu_submenu_roots[id] = root
menu_bind_submenu_position_events()
menu_position_submenu_root(root)
}
///|
#cfg(target="js")
fn menu_position_submenus_cmd(id : String) -> @cmd.Cmd {
@cmd.custom_cmd(kind=@cmd.AfterRender, _ => position_menu_submenus(id))
}
///|
#cfg(not(target="js"))
fn menu_position_submenus_cmd(id : String) -> @cmd.Cmd {
ignore(id)
@cmd.none
}
///|
#cfg(target="js")
fn menu_bind_surface_cmd(
id : String,
set_active : @cmd.Emit[Int],
close_command : @cmd.Cmd,
set_submenu : @cmd.Emit[MenuSubmenuChange],
) -> @cmd.Cmd {
@cmd.custom_cmd(kind=@cmd.AfterRender, scheduler => {
ui_after_mount(
id,
() => {
bind_menu_surface(id, (kind, index, key, parent) => {
let command = match kind {
0 => set_active(index)
1 => close_command
2 => set_submenu(MenuOpenSubmenu(key))
3 => set_submenu(MenuCloseSubmenu(key))
4 => set_submenu(MenuSwitchSubmenu(parent, key))
5 => set_submenu(MenuCloseSubmenusUnder(parent))
_ => set_submenu(MenuCloseAllSubmenus)
}
scheduler.add(command)
})
},
purpose="menu-surface-bind",
)
})
}
///|
#cfg(not(target="js"))
fn menu_bind_surface_cmd(
id : String,
set_active : @cmd.Emit[Int],
close_command : @cmd.Cmd,
set_submenu : @cmd.Emit[MenuSubmenuChange],
) -> @cmd.Cmd {
ignore((id, set_active, close_command, set_submenu))
@cmd.none
}
///|
#cfg(target="js")
fn menu_focus_item_cmd(
id : String,
index : Int,
content_id? : String = "",
) -> @cmd.Cmd {
@cmd.custom_cmd(kind=@cmd.AfterRender, _ => {
ui_after_ready("menu-focus-item:" + id, () => {
focus_menu_item(id, content_id, index)
})
})
}
///|
#cfg(not(target="js"))
fn menu_focus_item_cmd(
id : String,
index : Int,
content_id? : String = "",
) -> @cmd.Cmd {
ignore((id, index, content_id))
@cmd.none
}
///|
#cfg(target="js")
fn menu_focus_first_cmd(id : String, set_active : @cmd.Emit[Int]) -> @cmd.Cmd {
@cmd.custom_cmd(kind=@cmd.AfterRender, scheduler => {
focus_first_menu_item(id, index => scheduler.add(set_active(index)))
})
}
///|
#cfg(target="js")
fn menu_open_marked_cmd(
id : String,
slot : String,
set_index : @cmd.Emit[Int],
) -> @cmd.Cmd {
@cmd.custom_cmd(kind=@cmd.AfterRender, scheduler => {
ui_after_mount(
id,
() => {
find_open_menu_content(id, slot, (content_id, index) => {
ignore(set_floating_open(content_id, true))
position_floating(content_id)
if index >= 0 {
scheduler.add(set_index(index))
}
})
},
purpose="menu-open-marked",
)
})
}
///|
#cfg(not(target="js"))
fn menu_open_marked_cmd(
id : String,
slot : String,
set_index : @cmd.Emit[Int],
) -> @cmd.Cmd {
ignore((id, slot, set_index))
@cmd.none
}
///|
#cfg(not(target="js"))
fn menu_focus_first_cmd(id : String, set_active : @cmd.Emit[Int]) -> @cmd.Cmd {
ignore((id, set_active))
@cmd.none
}
///|
fn[C : @html.IsChildren] menu_root_surface(
slot : String,
id : String,
open : Bool,
attrs : @html.Attrs?,
style : Array[String],
children : C,
) -> @html.Html {
@html.span(
style=ui_styles([UiBoxSizing, MenuRootStyle], style),
id~,
attrs=menu_state_attrs(attrs, slot, open),
children,
)
}
///|
fn[C : @html.IsChildren] menu_trigger_button(
slot : String,
scope : MenuScope,
variant : ButtonVariant,
size : ButtonSize,
disabled : Bool,
aria_label : String?,
on_click : @cmd.Cmd?,
class : String?,
title : String?,
attrs : @html.Attrs?,
style : Array[String],
children : C,
) -> @html.Html {
let element_attrs = menu_state_attrs(attrs, slot, scope.model.open)
.aria_haspopup("menu")
.aria_expanded(ui_bool(scope.model.open))
.aria_controls(scope.content_id)
.data_set("variant", button_variant_value(variant))
.data_set("size", button_size_value(size))
if aria_label is Some(label) {
ignore(element_attrs.aria_label(label))
}
if disabled {
ignore(element_attrs.aria_disabled("true").data_set("disabled", ""))
} else {
if on_click is Some(on_click) {
ignore(element_attrs.on_click(_ => on_click))
}
ignore(element_attrs.on_click(_ => scope.toggle_command))
}
@html.button(
style=ui_styles(
[
UiBoxSizing,
UiFontSans,
UiTextRendering,
UiTransition,
ButtonBaseStyle,
button_variant_style(variant),
button_size_style(size),
ui_disabled_style(disabled),
popup_anchor_style(scope.trigger_id),
],
style,
),
id=scope.trigger_id,
class?,
title?,
type_="button",
disabled~,
attrs=element_attrs,
children,
)
}
///|
fn[C : @html.IsChildren] menu_content_surface(
slot : String,
scope : MenuScope,
side : PopupSide,
align : PopupAlign,
side_offset : Int,
align_offset : Int,
aria_label : String?,
class : String?,
title : String?,
attrs : @html.Attrs?,
style : Array[String],
children : C,
) -> @html.Html {
let is_context_menu = slot == "context-menu-content"
let element_attrs = menu_state_attrs(attrs, slot, scope.model.open)
.popover(if is_context_menu { "manual" } else { "auto" })
.data_set("light-dismiss", "true")
.role("menu")
.tabindex(-1)
.aria_orientation("vertical")
.data_set("trigger-id", scope.trigger_id)
.data_set("requested-side", popup_side_value(side))
.data_set("requested-align", popup_align_value(align))
.data_set("side", popup_side_value(side))
.data_set("align", popup_align_value(align))
.data_set("side-offset", "\{side_offset}")
.data_set("align-offset", "\{align_offset}")
.data_set("active-index", "\{scope.model.active}")
if aria_label is Some(label) {
ignore(element_attrs.aria_label(label))
} else {
ignore(element_attrs.aria_labelledby(scope.trigger_id))
}
@html.div(
style=ui_styles(
[
UiBoxSizing,
UiFontSans,
UiTextRendering,
MenuContentStyle,
PopupTransition100Style,
if is_context_menu {
popup_floating_anchor_style(
context_menu_point_id(scope.trigger_id),
Bottom,
Start,
0,
0,
)
} else {
popup_floating_anchor_style(
scope.trigger_id,
side,
align,
side_offset,
align_offset,
)
},
popup_state_style(scope.model.open),
],
style,
),
id=scope.content_id,
class?,
title?,
attrs=element_attrs,
children,
)
}
///|
fn[C : @html.IsChildren] menu_group_surface(
slot : String,
aria_label : String?,
class : String?,
title : String?,
attrs : @html.Attrs?,
style : Array[String],
children : C,
) -> @html.Html {
let element_attrs = ui_attrs(attrs).data_set("slot", slot).role("group")
if aria_label is Some(label) {
ignore(element_attrs.aria_label(label))
}
@html.div(
style=ui_styles([UiBoxSizing, "display:flex;flex-direction:column"], style),
class?,
title?,
attrs=element_attrs,
children,
)
}
///|
fn[C : @html.IsChildren] menu_label_surface(
slot : String,
inset : Bool,
class : String?,
title : String?,
attrs : @html.Attrs?,
style : Array[String],
children : C,
) -> @html.Html {
@html.div(
style=ui_styles(
[UiBoxSizing, MenuLabelStyle, if inset { MenuItemInsetStyle } else { "" }],
style,
),
class?,
title?,
attrs=ui_attrs(attrs).data_set("slot", slot),
children,
)
}
///|
fn menu_separator_surface(
slot : String,
class : String?,
attrs : @html.Attrs?,
style : Array[String],
) -> @html.Html {
@html.div(
style=ui_styles([UiBoxSizing, MenuSeparatorStyle], style),
class?,
attrs=ui_attrs(attrs)
.data_set("slot", slot)
.role("separator")
.aria_orientation("horizontal"),
@html.nothing,
)
}
///|
fn[C : @html.IsChildren] menu_shortcut_surface(
slot : String,
class : String?,
title : String?,
attrs : @html.Attrs?,
style : Array[String],
children : C,
) -> @html.Html {
@html.span(
style=ui_styles([UiBoxSizing, MenuShortcutStyle], style),
class?,
title?,
attrs=ui_attrs(attrs).data_set("slot", slot).aria_hidden("true"),
children,
)
}
///|
fn[C : @html.IsChildren] menu_item_surface(
slot : String,
scope : MenuScope,
role : String,
checked : Bool?,
indicator_placement : MenuIndicatorPlacement,
disabled : Bool,
inset : Bool,
destructive : Bool,
submenu_key : String?,
close_on_select : Bool,
action : @cmd.Cmd?,
on_select : @cmd.Cmd?,
aria_label : String?,
class : String?,
title : String?,
attrs : @html.Attrs?,
style : Array[String],
children : C,
) -> @html.Html {
let index = menu_scope_next_index(scope)
let highlighted = scope.model.active == index
let element_attrs = ui_attrs(attrs)
.data_set("slot", slot)
.data_set("variant", if destructive { "destructive" } else { "default" })
.data_set("menu-index", "\{index}")
.data_set("highlighted", ui_bool(highlighted))
.role(role)
.tabindex(if highlighted { 0 } else { -1 })
if highlighted {
ignore(element_attrs.data_set("active", ""))
}
if checked is Some(checked) {
ignore(
element_attrs
.aria_checked(ui_bool(checked))
.data_set("state", if checked { "checked" } else { "unchecked" }),
)
}
if submenu_key is Some(key) {
let open = menu_submenu_open(scope.model.submenus, key)
ignore(
element_attrs
.id(menu_submenu_trigger_id(key))
.data_set("submenu-key", key)
.data_set("state", if open { "open" } else { "closed" })
.aria_haspopup("menu")
.aria_expanded(ui_bool(open))
.aria_controls(menu_submenu_content_id(key)),
)
}
if aria_label is Some(label) {
ignore(element_attrs.aria_label(label).data_set("menu-label", label))
}
if disabled {
ignore(element_attrs.aria_disabled("true").data_set("disabled", ""))
} else {
if on_select is Some(on_select) {
ignore(element_attrs.on_click(_ => on_select))
}
if action is Some(action) {
ignore(element_attrs.on_click(_ => action))
}
if close_on_select {
ignore(element_attrs.on_click(_ => scope.close_command))
}
}
let body = @html.span(
style=[UiBoxSizing, "display:contents"],
attrs=@html.Attrs::build().data_set("slot", slot + "-body"),
children,
)
let row = if checked is Some(checked) {
[
@html.span(
style=[
UiBoxSizing,
match indicator_placement {
MenuIndicatorInlineStart => MenuIndicatorStartStyle
MenuIndicatorInlineEnd => MenuIndicatorEndStyle
},
],
attrs=@html.Attrs::build()
.data_set("slot", slot + "-indicator")
.aria_hidden("true"),
if checked {
ui_check_icon()
} else {
@html.nothing
},
),
body,
]
} else if submenu_key is Some(_) {
[
body,
@html.span(
style=[UiBoxSizing, MenuIndicatorEndStyle],
attrs=@html.Attrs::build()
.aria_hidden("true")
.data_set("icon", "inline-end"),
ui_chevron_right_icon(),
),
]
} else {
[body]
}
@html.button(
style=ui_styles(
[
UiBoxSizing,
UiFontSans,
UiTextRendering,
UiTransition,
MenuItemStyle,
if (checked is Some(_) && indicator_placement == MenuIndicatorInlineEnd) ||
submenu_key is Some(_) {
MenuItemEndIndicatorStyle
} else {
""
},
if inset {
MenuItemInsetStyle
} else {
""
},
if destructive {
MenuItemDestructiveStyle
} else {
""
},
if highlighted {
MenuItemHighlightedStyle
} else {
""
},
match submenu_key {
Some(key) => popup_anchor_style(key)
None => ""
},
ui_disabled_style(disabled),
],
style,
),
class?,
title?,
type_="button",
disabled~,
attrs=element_attrs,
row,
)
}
///|
fn[C : @html.IsChildren] menu_sub_root_surface(
slot : String,
open : Bool,
attrs : @html.Attrs?,
style : Array[String],
children : C,
) -> @html.Html {
@html.div(
style=ui_styles([UiBoxSizing, "position:relative;display:contents"], style),
attrs=menu_state_attrs(attrs, slot, open),
children,
)
}
///|
fn[C : @html.IsChildren] menu_sub_content_surface(
slot : String,
key : String,
open : Bool,
class : String?,
title : String?,
attrs : @html.Attrs?,
style : Array[String],
children : C,
) -> @html.Html {
@html.div(
style=ui_styles(
[
UiBoxSizing,
UiFontSans,
MenuSubContentStyle,
PopupTransition100Style,
menu_submenu_anchor_style(key),
popup_state_style(open),
],
style,
),
class?,
title?,
attrs=menu_state_attrs(attrs, slot, open)
.id(menu_submenu_content_id(key))
.popover("manual")
.data_set("restore-focus", "false")
.data_set("submenu-owner", key)
.data_set("requested-side", "inline-end")
.data_set("requested-align", "start")
.data_set("positioned", if open { "pending" } else { "false" })
.role("menu")
.aria_orientation("vertical")
.aria_labelledby(menu_submenu_trigger_id(key))
.tabindex(-1),
children,
)
}