///|
struct NavigationMenuModel {
value : String?
index : Int
focus_index : Int
} derive(Eq)
///|
struct NavigationMenuScope {
id : String
model : NavigationMenuModel
counter : Ref[Int]
focus_claimed : Ref[Bool]
viewport : Bool
contents : Ref[Array[NavigationMenuContentEntry]]
viewport_rendered : Ref[Bool]
toggle : @cmd.Emit[(String, Int)]
}
///|
struct NavigationMenuItemScope {
id : String
value : String
index : Int
open : Bool
focus_index : Int
focus_claimed : Ref[Bool]
active_value_set : Bool
viewport : Bool
contents : Ref[Array[NavigationMenuContentEntry]]
toggle_command : @cmd.Cmd
}
///|
priv struct NavigationMenuContentEntry {
value : String
content : @html.Html
}
///|
#cfg(target="js")
priv enum NavigationMenuMsg {
NavigationActivate(String, Int)
NavigationToggle(String, Int)
NavigationClose
NavigationNative(String, Int, Bool)
NavigationSetFocus(Int)
}
///|
const NavigationMenuRootStyle : String = "position:relative;display:flex;width:max-content;max-width:100%;min-width:0;align-items:center;justify-content:center"
///|
const NavigationMenuListStyle : String = "display:flex;max-width:100%;flex-wrap:wrap;list-style:none;align-items:center;justify-content:center;gap:0.25rem;margin:0;padding:0"
///|
const NavigationMenuItemStyle : String = "position:relative;display:flex;align-items:center"
///|
const NavigationMenuTriggerStyle : String = "display:inline-flex;height:2.25rem;max-width:100%;min-width:0;align-items:center;justify-content:center;gap:0.375rem;--rui-nav-base-bg:var(--rui-background,oklch(1 0 0));--rui-nav-base-fg:var(--rui-foreground,oklch(0.145 0 0));--rui-nav-base-border:transparent;--rui-nav-base-shadow:none;border:1px solid transparent;border-color:var(--rui-nav-border,var(--rui-nav-base-border,transparent));border-radius:calc(var(--rui-radius,0.625rem) - 0.125rem);background:var(--rui-nav-bg,var(--rui-nav-base-bg,var(--rui-background,oklch(1 0 0))));padding-inline:0.75rem;color:var(--rui-nav-fg,var(--rui-nav-base-fg,var(--rui-foreground,oklch(0.145 0 0))));box-shadow:var(--rui-nav-shadow,var(--rui-nav-base-shadow,none));font-size:0.875rem;line-height:1.25rem;font-weight:500;white-space:nowrap;outline-offset:2px"
///|
const NavigationMenuTriggerOpenStyle : String = "--rui-nav-base-bg:color-mix(in oklab,var(--rui-accent,oklch(0.97 0 0)) 50%,transparent);--rui-nav-base-fg:var(--rui-accent-foreground,oklch(0.205 0 0))"
///|
const NavigationMenuContentStyle : String = "position:fixed;inset:auto;left:var(--rui-floating-left,auto);top:var(--rui-floating-top,auto);z-index:50;display:block;width:min(32rem,calc(100vw - 1rem));max-height:calc(100vh - 1rem);overflow:auto;margin:0;border:1px solid color-mix(in oklab,var(--rui-foreground,oklch(0.145 0 0)) 10%,transparent);border-radius:var(--rui-radius,0.625rem);background:var(--rui-popover,oklch(1 0 0));padding:1rem;color:var(--rui-popover-foreground,oklch(0.145 0 0));box-shadow:0 12px 32px rgb(0 0 0 / 0.14);outline:none;transition:opacity 150ms ease,transform 150ms ease,visibility 150ms ease"
///|
const NavigationMenuViewportContentStyle : String = "position:relative;display:block;width:min(32rem,calc(100vw - 1rem));max-height:calc(100dvh - 1rem);overflow:auto;padding:1rem;color:var(--rui-popover-foreground,var(--rui-foreground,oklch(0.145 0 0)));outline:none"
///|
const NavigationMenuViewportStyle : String = "position:fixed;inset:auto;top:0;left:50%;z-index:50;--rui-navigation-viewport-half:min(16rem,calc(50vw - 0.5rem));width:min(32rem,calc(100vw - 1rem));max-width:calc(100vw - 1rem);max-height:calc(100dvh - 1rem);overflow:hidden;transform:translateX(-50%);position-try-fallbacks:flip-block;border:1px solid var(--rui-border,oklch(0.922 0 0));border-radius:var(--rui-radius,0.625rem);background:var(--rui-popover,oklch(1 0 0));box-shadow:0 12px 32px rgb(0 0 0 / 0.14);transition:opacity 150ms ease,visibility 150ms ease"
///|
const NavigationMenuLinkStyle : String = "display:block;min-width:0;--rui-nav-base-bg:transparent;--rui-nav-base-fg:var(--rui-foreground,oklch(0.145 0 0));--rui-nav-base-border:transparent;--rui-nav-base-shadow:none;border:1px solid transparent;border-color:var(--rui-nav-border,var(--rui-nav-base-border,transparent));border-radius:calc(var(--rui-radius,0.625rem) - 0.125rem);background:var(--rui-nav-bg,var(--rui-nav-base-bg,transparent));padding:0.625rem 0.75rem;color:var(--rui-nav-fg,var(--rui-nav-base-fg,var(--rui-foreground,oklch(0.145 0 0))));box-shadow:var(--rui-nav-shadow,var(--rui-nav-base-shadow,none));text-decoration:none;outline-offset:2px;overflow-wrap:anywhere"
///|
const NavigationMenuLinkActiveStyle : String = "--rui-nav-base-bg:color-mix(in oklab,var(--rui-accent,oklch(0.97 0 0)) 50%,transparent);--rui-nav-base-fg:var(--rui-accent-foreground,oklch(0.205 0 0))"
///|
const NavigationMenuIndicatorStyle : String = "position:absolute;top:100%;inset-inline:0;z-index:1;display:flex;height:0.375rem;align-items:flex-end;justify-content:center;overflow:hidden;transition:opacity 150ms ease,visibility 150ms ease"
///|
const NavigationMenuIndicatorArrowStyle : String = "position:relative;top:60%;width:0.5rem;height:0.5rem;flex:none;transform:rotate(45deg);border-radius:2px 0 0;background:var(--rui-border,oklch(0.922 0 0));box-shadow:0 4px 6px -1px rgb(0 0 0 / 0.1),0 2px 4px -2px rgb(0 0 0 / 0.1)"
///|
const NavigationMenuChevronBoxStyle : String = "display:inline-flex;width:1rem;height:1rem;flex-shrink:0;align-items:center;justify-content:center;transition:transform 150ms ease"
///|
const NavigationMenuChevronStyle : String = "display:block;width:0.4375rem;height:0.4375rem;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:translateY(-0.125rem) rotate(45deg)"
///|
fn navigation_content_id(id : String, index : Int) -> String {
id + "-content-\{index}"
}
///|
fn navigation_trigger_id(id : String, index : Int) -> String {
id + "-trigger-\{index}"
}
///|
fn navigation_viewport_anchor_id(id : String) -> String {
id + "-viewport-anchor"
}
///|
fn navigation_viewport_position_style(id : String) -> String {
let anchor = popup_anchor_name(navigation_viewport_anchor_id(id))
"position-anchor:\{anchor};top:calc(anchor(bottom) + 0.375rem);left:clamp(calc(var(--rui-navigation-viewport-half) + 0.5rem),anchor(center),calc(100vw - var(--rui-navigation-viewport-half) - 0.5rem))"
}
///|
#cfg(target="js")
priv struct NavigationMenuBinding {
id : String
root : @dom.Element
document : @dom.Document
activate : Ref[(String, Int) -> Unit]
native_change : Ref[(String, Int, Bool) -> Unit]
close : Ref[() -> Unit]
set_focus : Ref[(Int) -> Unit]
toggle_contents : Array[@dom.Element]
close_timer : Ref[Int]
outside_press : Ref[@dom.Listener]
}
///|
#cfg(target="js")
let navigation_menu_bindings : Map[String, NavigationMenuBinding] = Map([])
///|
#cfg(target="js")
fn navigation_menu_all_triggers(
binding : NavigationMenuBinding,
) -> Array[@dom.Element] {
binding.root.query_selector_all("[data-slot=\"navigation-menu-trigger\"]")
}
///|
#cfg(target="js")
fn navigation_menu_triggers(
binding : NavigationMenuBinding,
) -> Array[@dom.Element] {
navigation_menu_all_triggers(binding).filter(ui_element_is_enabled)
}
///|
#cfg(target="js")
fn navigation_menu_normalize_tab_stop(binding : NavigationMenuBinding) -> Unit {
let all = navigation_menu_all_triggers(binding)
let enabled = all.filter(ui_element_is_enabled)
let mut target = ui_find_element(enabled, item => {
item.get_attribute("aria-expanded").unwrap_or("") == "true"
})
if target is None {
target = ui_find_element(enabled, item => {
item.to_html_element() is Some(html) && html.get_tab_index() == 0
})
}
if target is None {
target = enabled.get(0)
}
for item in all {
ui_set_element_tab_index(
item,
if target is Some(target) && item.is_same_node(target.as_node()) {
0
} else {
-1
},
)
}
}
///|
#cfg(target="js")
fn navigation_menu_open_trigger(
binding : NavigationMenuBinding,
trigger : @dom.Element,
) -> Unit {
(binding.activate.val)(
trigger.get_attribute("data-navigation-value").unwrap_or(""),
ui_parse_int_or(
trigger.get_attribute("data-navigation-index").unwrap_or(""),
-1,
),
)
}
///|
#cfg(target="js")
fn navigation_menu_content_for_index(
root : @dom.Element,
index : Int,
) -> @dom.Element? {
ui_find_element(
root.query_selector_all("[data-slot=\"navigation-menu-content\"]"),
content => {
ui_parse_int_or(
content.get_attribute("data-navigation-index").unwrap_or(""),
-1,
) ==
index &&
menu_element_is_visible(content)
},
)
}
///|
#cfg(target="js")
fn navigation_menu_focus_content(
binding : NavigationMenuBinding,
index : Int,
attempt : Int,
) -> Unit {
menu_request_frame(() => {
guard ui_element_by_id(binding.id) is Some(root) && root.get_is_connected() else {
return
}
if navigation_menu_content_for_index(root, index) is Some(content) {
let selector = "a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex=\"-1\"])"
let target = ui_find_element(
content.query_selector_all(selector),
menu_element_is_visible,
).unwrap_or(content)
ui_focus_element(target)
} else if attempt < 3 {
navigation_menu_focus_content(binding, index, attempt + 1)
}
})
}
///|
#cfg(target="js")
fn navigation_menu_clear_close_timer(binding : NavigationMenuBinding) -> Unit {
if binding.close_timer.val != 0 {
if ui_has_window() {
@dom.window().clear_timeout(binding.close_timer.val)
}
binding.close_timer.val = 0
}
}
///|
#cfg(target="js")
fn navigation_menu_schedule_close(binding : NavigationMenuBinding) -> Unit {
navigation_menu_clear_close_timer(binding)
let finish = () => {
binding.close_timer.val = 0
if binding.root.get_is_connected() && !binding.root.matches(":focus-within") {
(binding.close.val)()
}
}
if ui_has_window() {
binding.close_timer.val = @dom.window().set_timeout(finish, 100)
} else {
finish()
}
}
///|
#cfg(target="js")
fn navigation_menu_bind_content_toggles(
binding : NavigationMenuBinding,
) -> Unit {
for
content in binding.root.query_selector_all(
"[data-slot=\"navigation-menu-content\"]",
) {
let mut already_bound = false
for current in binding.toggle_contents {
if current.is_same_node(content.as_node()) {
already_bound = true
}
}
if !already_bound {
binding.toggle_contents.push(content)
content.add_event_listener("toggle", _ => {
(binding.native_change.val)(
content.get_attribute("data-navigation-value").unwrap_or(""),
ui_parse_int_or(
content.get_attribute("data-navigation-index").unwrap_or(""),
-1,
),
content.matches(":popover-open"),
)
})
}
}
}
///|
#cfg(target="js")
fn navigation_menu_handle_keydown(
binding : NavigationMenuBinding,
event : @dom.KeyboardEvent,
) -> Unit {
if event.get_default_prevented() {
return
}
guard event.target().to_element() is Some(target) else { return }
let trigger = target.closest("[data-slot=\"navigation-menu-trigger\"]")
let content = target.closest("[data-slot=\"navigation-menu-content\"]")
let key = event.key()
if trigger is None && content is Some(content) && key == "Escape" {
event.prevent_default()
let index = ui_parse_int_or(
content.get_attribute("data-navigation-index").unwrap_or(""),
-1,
)
(binding.close.val)()
let destination = ui_find_element(navigation_menu_all_triggers(binding), item => {
ui_parse_int_or(
item.get_attribute("data-navigation-index").unwrap_or(""),
-1,
) ==
index
})
if destination is Some(destination) {
ui_focus_element(destination)
}
return
}
guard trigger is Some(trigger) else { return }
let items = navigation_menu_triggers(binding)
if items.length() == 0 {
return
}
let mut current = 0
for item_index, item in items {
if item.is_same_node(trigger.as_node()) {
current = item_index
}
}
let rtl = ui_element_is_rtl(binding.root)
let forward_key = if rtl { "ArrowLeft" } else { "ArrowRight" }
let backward_key = if rtl { "ArrowRight" } else { "ArrowLeft" }
let mut next : @dom.Element? = None
if key == forward_key {
next = items.get((current + 1) % items.length())
} else if key == backward_key {
next = items.get(
if current == 0 {
items.length() - 1
} else {
current - 1
},
)
} else if key == "Home" {
next = items.get(0)
} else if key == "End" {
next = items.get(items.length() - 1)
}
if key == "ArrowDown" {
event.prevent_default()
navigation_menu_open_trigger(binding, trigger)
navigation_menu_focus_content(
binding,
ui_parse_int_or(
trigger.get_attribute("data-navigation-index").unwrap_or(""),
-1,
),
0,
)
return
}
if key == "Escape" {
event.prevent_default()
(binding.close.val)()
ui_focus_element(trigger)
return
}
if next is Some(next) {
event.prevent_default()
ui_focus_element(next)
if binding.root.get_attribute("data-state").unwrap_or("") == "open" {
navigation_menu_open_trigger(binding, next)
}
}
}
///|
#cfg(target="js")
fn bind_navigation_menu(
id : String,
activate : (String, Int) -> Unit,
native_change : (String, Int, Bool) -> Unit,
close : () -> Unit,
set_focus : (Int) -> Unit,
) -> Unit {
guard ui_element_by_id(id) is Some(root) else { return }
let document = @dom.document()
if navigation_menu_bindings.get(id) is Some(existing) {
if existing.root.is_same_node(root.as_node()) {
existing.activate.val = activate
existing.native_change.val = native_change
existing.close.val = close
existing.set_focus.val = set_focus
navigation_menu_bind_content_toggles(existing)
navigation_menu_normalize_tab_stop(existing)
return
}
navigation_menu_clear_close_timer(existing)
existing.document.remove_event_listener_with_options(
"pointerdown",
existing.outside_press.val,
capture=true,
)
}
let binding : NavigationMenuBinding = {
id,
root,
document,
activate: Ref(activate),
native_change: Ref(native_change),
close: Ref(close),
set_focus: Ref(set_focus),
toggle_contents: [],
close_timer: Ref(0),
outside_press: Ref(_ => ()),
}
navigation_menu_bindings[id] = binding
navigation_menu_bind_content_toggles(binding)
navigation_menu_normalize_tab_stop(binding)
root.add_event_listener("focusin", event => {
guard event.target().to_element() is Some(target) else { return }
guard target.closest("[data-slot=\"navigation-menu-trigger\"]")
is Some(trigger) &&
ui_element_is_enabled(trigger) else {
return
}
let index = ui_parse_int_or(
trigger.get_attribute("data-navigation-index").unwrap_or(""),
-1,
)
for item in navigation_menu_all_triggers(binding) {
ui_set_element_tab_index(
item,
if item.is_same_node(trigger.as_node()) {
0
} else {
-1
},
)
}
if index >= 0 {
(binding.set_focus.val)(index)
}
})
root.add_event_listener("focusout", _ => {
let finish = () => {
let active_inside = binding.document.get_active_element() is Some(active) &&
binding.root.contains(active.as_node())
if binding.root.get_attribute("data-state").unwrap_or("") == "open" &&
!active_inside {
(binding.close.val)()
}
}
if ui_has_window() {
@dom.window().queue_microtask(finish)
} else {
finish()
}
})
root.add_event_listener("click", event => {
guard event.target().to_element() is Some(target) else { return }
if target.closest("[data-slot=\"navigation-menu-link\"]") is Some(link) &&
binding.root.contains(link.as_node()) &&
ui_element_is_enabled(link) {
(binding.close.val)()
}
})
let outside_press : @dom.Listener = event => {
if !binding.root.get_is_connected() {
binding.document.remove_event_listener_with_options(
"pointerdown",
binding.outside_press.val,
capture=true,
)
return
}
let inside = event.target().to_element() is Some(target) &&
binding.root.contains(target.as_node())
if binding.root.get_attribute("data-state").unwrap_or("") == "open" &&
!inside {
(binding.close.val)()
}
}
binding.outside_press.val = outside_press
document.add_event_listener_with_options(
"pointerdown",
outside_press,
capture=true,
)
root.add_event_listener_with_options(
"pointerenter",
event => {
navigation_menu_clear_close_timer(binding)
guard event.target().to_element() is Some(target) else { return }
if target.closest("[data-slot=\"navigation-menu-trigger\"]")
is Some(trigger) &&
ui_element_is_enabled(trigger) {
navigation_menu_open_trigger(binding, trigger)
}
},
capture=true,
)
root.add_event_listener("pointerleave", _ => {
navigation_menu_schedule_close(binding)
})
root.add_event_listener("keydown", event => {
if event.to_keyboard_event() is Some(keyboard_event) {
navigation_menu_handle_keydown(binding, keyboard_event)
}
})
}
///|
#cfg(target="js")
fn navigation_bind_cmd(
id : String,
activate : @cmd.Emit[(String, Int)],
native_change : @cmd.Emit[(String, Int, Bool)],
close_command : @cmd.Cmd,
set_focus : @cmd.Emit[Int],
) -> @cmd.Cmd {
@cmd.custom_cmd(kind=@cmd.AfterRender, scheduler => {
ui_after_mount(
id,
() => {
bind_navigation_menu(
id,
(value, index) => scheduler.add(activate((value, index))),
(value, index, open) => {
scheduler.add(native_change((value, index, open)))
},
() => scheduler.add(close_command),
index => scheduler.add(set_focus(index)),
)
},
purpose="navigation-menu-bind",
)
})
}
///|
fn navigation_change_cmd(
id : String,
current : NavigationMenuModel,
value : String?,
index : Int,
viewport : Bool,
) -> @cmd.Cmd {
@cmd.batch([
if !viewport && current.index >= 0 && current.value != value {
floating_set_open_cmd(navigation_content_id(id, current.index), false)
} else {
@cmd.none
},
if !viewport && value is Some(_) {
floating_set_open_cmd(navigation_content_id(id, index), true)
} else {
@cmd.none
},
])
}
///|
#cfg(target="js")
pub fn navigation_menu(
id~ : String,
default_value? : String,
viewport? : Bool = true,
on_value_change? : @cmd.Emit[String?],
aria_label? : String = "Main navigation",
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : (NavigationMenuScope) -> @html.Html,
) -> @rabbita.Val[@html.Html] {
let initial : NavigationMenuModel = {
value: default_value,
index: if default_value is Some(_) {
0
} else {
-1
},
focus_index: -1,
}
let (model, emit) = @rabbita.create_state_with_init(
init=emit => {
(
initial,
@cmd.batch([
navigation_bind_cmd(
id + "-root",
emit.map(pair => NavigationActivate(pair.0, pair.1)),
emit.map(tuple => NavigationNative(tuple.0, tuple.1, tuple.2)),
emit(NavigationClose),
emit.map(index => NavigationSetFocus(index)),
),
if viewport {
@cmd.none
} else {
match default_value {
Some(value) =>
menu_open_marked_cmd(
id + "-root",
"navigation-menu-content",
emit.map(index => NavigationNative(value, index, true)),
)
None => @cmd.none
}
},
]),
)
},
update=(_, msg, current) => {
match msg {
NavigationActivate(value, index) =>
if current.value == Some(value) {
(current, @cmd.none)
} else {
let next : NavigationMenuModel = {
value: Some(value),
index,
focus_index: index,
}
(
next,
@cmd.batch([
navigation_change_cmd(id, current, Some(value), index, viewport),
if on_value_change is Some(notify) {
notify(Some(value))
} else {
@cmd.none
},
]),
)
}
NavigationToggle(value, index) =>
if current.value == Some(value) {
(
{ value: None, index: -1, focus_index: index },
@cmd.batch([
if viewport {
@cmd.none
} else {
floating_set_open_cmd(navigation_content_id(id, index), false)
},
if on_value_change is Some(notify) {
notify(None)
} else {
@cmd.none
},
]),
)
} else {
(
{ value: Some(value), index, focus_index: index },
@cmd.batch([
navigation_change_cmd(id, current, Some(value), index, viewport),
if on_value_change is Some(notify) {
notify(Some(value))
} else {
@cmd.none
},
]),
)
}
NavigationClose =>
if current.value is Some(_) {
(
{ value: None, index: -1, focus_index: current.index },
@cmd.batch([
if viewport {
@cmd.none
} else {
floating_set_open_cmd(
navigation_content_id(id, current.index),
false,
)
},
if on_value_change is Some(notify) {
notify(None)
} else {
@cmd.none
},
]),
)
} else {
(current, @cmd.none)
}
NavigationNative(value, index, open) =>
if open {
({ value: Some(value), index, focus_index: index }, @cmd.none)
} else if current.value == Some(value) {
(
{ value: None, index: -1, focus_index: index },
if on_value_change is Some(notify) {
notify(None)
} else {
@cmd.none
},
)
} else {
(current, @cmd.none)
}
NavigationSetFocus(index) =>
({ ..current, focus_index: index }, @cmd.none)
}
},
)
model.view(model => {
let scope : NavigationMenuScope = {
id,
model,
counter: Ref(0),
focus_claimed: Ref(false),
viewport,
contents: Ref([]),
viewport_rendered: Ref(false),
toggle: emit.map(pair => NavigationToggle(pair.0, pair.1)),
}
let element_attrs = menu_state_attrs(
attrs,
"navigation-menu",
model.value is Some(_),
)
ignore(element_attrs.aria_label(aria_label))
if model.value is Some(value) {
ignore(element_attrs.data_set("value", value))
}
let rendered_children = children(scope)
let automatic_viewport = if viewport && !scope.viewport_rendered.val {
render_navigation_menu_viewport(
scope,
None,
None,
None,
[],
@html.nothing,
)
} else {
@html.nothing
}
@html.nav(
style=ui_styles(
[UiBoxSizing, UiFontSans, UiTextRendering, NavigationMenuRootStyle],
style,
),
id=id + "-root",
class?,
title?,
attrs=element_attrs,
[rendered_children, automatic_viewport],
)
})
}
///|
#cfg(not(target="js"))
pub fn navigation_menu(
id~ : String,
default_value? : String,
viewport? : Bool = true,
on_value_change? : @cmd.Emit[String?],
aria_label? : String = "Main navigation",
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : (NavigationMenuScope) -> @html.Html,
) -> @rabbita.Val[@html.Html] {
ignore(on_value_change)
let model : NavigationMenuModel = {
value: default_value,
index: if default_value is Some(_) {
0
} else {
-1
},
focus_index: -1,
}
let scope : NavigationMenuScope = {
id,
model,
counter: Ref(0),
focus_claimed: Ref(false),
viewport,
contents: Ref([]),
viewport_rendered: Ref(false),
toggle: menu_noop_emit(),
}
let element_attrs = menu_state_attrs(
attrs,
"navigation-menu",
default_value is Some(_),
)
ignore(element_attrs.aria_label(aria_label))
if default_value is Some(value) {
ignore(element_attrs.data_set("value", value))
}
let rendered_children = children(scope)
let automatic_viewport = if viewport && !scope.viewport_rendered.val {
render_navigation_menu_viewport(scope, None, None, None, [], @html.nothing)
} else {
@html.nothing
}
@rabbita.Val::constant(
@html.nav(
style=ui_styles(
[UiBoxSizing, UiFontSans, UiTextRendering, NavigationMenuRootStyle],
style,
),
id=id + "-root",
class?,
title?,
attrs=element_attrs,
[rendered_children, automatic_viewport],
),
)
}
///|
/// Explicit Root spelling matching the compound-component API.
pub fn navigation_menu_root(
id~ : String,
default_value? : String,
viewport? : Bool = true,
on_value_change? : @cmd.Emit[String?],
aria_label? : String = "Main navigation",
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : (NavigationMenuScope) -> @html.Html,
) -> @rabbita.Val[@html.Html] {
navigation_menu(
id~,
default_value?,
viewport~,
on_value_change?,
aria_label~,
class?,
title?,
attrs?,
style~,
children,
)
}
///|
pub fn[C : @html.IsChildren] navigation_menu_list(
scope : NavigationMenuScope,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
ignore(scope)
@html.ul(
style=ui_styles([UiBoxSizing, NavigationMenuListStyle], style),
class?,
title?,
attrs=ui_attrs(attrs).data_set("slot", "navigation-menu-list"),
children,
)
}
///|
pub fn[C : @html.IsChildren] navigation_menu_item(
scope : NavigationMenuScope,
value~ : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : (NavigationMenuItemScope) -> C,
) -> @html.Html {
let index = scope.counter.val
scope.counter.val += 1
let open = scope.model.value == Some(value)
let item_scope : NavigationMenuItemScope = {
id: scope.id,
value,
index,
open,
focus_index: scope.model.focus_index,
focus_claimed: scope.focus_claimed,
active_value_set: scope.model.value is Some(_),
viewport: scope.viewport,
contents: scope.contents,
toggle_command: (scope.toggle)((value, index)),
}
@html.li(
style=ui_styles([UiBoxSizing, NavigationMenuItemStyle], style),
class?,
title?,
attrs=menu_state_attrs(attrs, "navigation-menu-item", open)
.data_set("value", value)
.data_set("navigation-index", "\{index}"),
children(item_scope),
)
}
///|
pub fn[C : @html.IsChildren] navigation_menu_trigger(
scope : NavigationMenuItemScope,
disabled? : Bool = false,
aria_label? : String,
on_click? : @cmd.Cmd,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
let preferred = if scope.active_value_set {
scope.open
} else if scope.focus_index >= 0 {
scope.focus_index == scope.index
} else {
!scope.focus_claimed.val
}
let tab_stop = !disabled && preferred
let element_attrs = menu_state_attrs(
attrs,
"navigation-menu-trigger",
scope.open,
)
.data_set("navigation-value", scope.value)
.data_set("navigation-index", "\{scope.index}")
.aria_expanded(ui_bool(scope.open))
.aria_controls(navigation_content_id(scope.id, scope.index))
ignore(element_attrs.tabindex(if tab_stop { 0 } else { -1 }))
if tab_stop {
scope.focus_claimed.val = true
}
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,
UiTransition,
NavigationMenuTriggerStyle,
if scope.viewport {
if scope.open {
popup_anchor_style(navigation_viewport_anchor_id(scope.id))
} else {
""
}
} else {
popup_anchor_style(navigation_trigger_id(scope.id, scope.index))
},
if scope.open {
NavigationMenuTriggerOpenStyle
} else {
""
},
ui_disabled_style(disabled),
],
style,
),
id=navigation_trigger_id(scope.id, scope.index),
class?,
title?,
type_="button",
disabled~,
attrs=element_attrs,
[
@html.span(style=[UiBoxSizing, "display:contents"], children),
@html.span(
style=[
UiBoxSizing,
NavigationMenuChevronBoxStyle,
if scope.open {
"transform:rotate(180deg)"
} else {
""
},
],
attrs=@html.Attrs::build()
.aria_hidden("true")
.data_set("icon", "chevron-down"),
@html.span(
style=[UiBoxSizing, NavigationMenuChevronStyle],
@html.nothing,
),
),
],
)
}
///|
pub fn[C : @html.IsChildren] navigation_menu_content(
scope : NavigationMenuItemScope,
side_offset? : Int = 8,
align_offset? : Int = 0,
aria_label? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
let trigger_id = navigation_trigger_id(scope.id, scope.index)
let element_attrs = menu_state_attrs(
attrs,
"navigation-menu-content",
scope.open,
)
.role("region")
.tabindex(-1)
.aria_labelledby(trigger_id)
.data_set("trigger-id", trigger_id)
.data_set("requested-side", "bottom")
.data_set("requested-align", "center")
.data_set("side", "bottom")
.data_set("align", "center")
.data_set("side-offset", "\{side_offset}")
.data_set("align-offset", "\{align_offset}")
.data_set("navigation-value", scope.value)
.data_set("navigation-index", "\{scope.index}")
if !scope.viewport {
ignore(element_attrs.popover("auto"))
}
if aria_label is Some(label) {
ignore(element_attrs.aria_label(label))
}
let content = @html.div(
style=ui_styles(
[
UiBoxSizing,
UiFontSans,
UiTextRendering,
if scope.viewport {
NavigationMenuViewportContentStyle
} else {
NavigationMenuContentStyle
},
if scope.viewport {
""
} else {
PopupTransition150Style
},
if scope.viewport {
""
} else {
popup_floating_anchor_style(
trigger_id,
Bottom,
Center,
side_offset,
align_offset,
)
},
if scope.viewport {
""
} else {
popup_state_style(scope.open)
},
],
style,
),
id=navigation_content_id(scope.id, scope.index),
class?,
title?,
attrs=element_attrs,
children,
)
if scope.viewport {
scope.contents.val.push({ value: scope.value, content })
@html.nothing
} else {
content
}
}
///|
fn render_navigation_menu_viewport(
scope : NavigationMenuScope,
class : String?,
title : String?,
attrs : @html.Attrs?,
style : Array[String],
custom : @html.Html,
) -> @html.Html {
let viewport_children : Array[@html.Html] = []
let open = scope.model.value is Some(_)
for entry in scope.contents.val {
if scope.model.value == Some(entry.value) {
viewport_children.push(entry.content)
}
}
viewport_children.push(custom)
@html.div(
style=ui_styles(
[
UiBoxSizing,
NavigationMenuViewportStyle,
navigation_viewport_position_style(scope.id),
if scope.model.value is Some(_) {
"visibility:visible;opacity:1;pointer-events:auto"
} else {
"visibility:hidden;opacity:0;pointer-events:none"
},
],
style,
),
class?,
title?,
attrs=menu_state_attrs(attrs, "navigation-menu-viewport", open)
.aria_hidden(ui_bool(!open))
.inert(!open),
viewport_children,
)
}
///|
/// Render the shared viewport. Content parts registered by the active menu
/// item are mounted here; optional children are appended for custom chrome.
pub fn[C : @html.IsChildren] navigation_menu_viewport(
scope : NavigationMenuScope,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
scope.viewport_rendered.val = true
render_navigation_menu_viewport(
scope,
class,
title,
attrs,
style,
@html.div(style=[UiBoxSizing, "display:contents"], children),
)
}
///|
pub fn[C : @html.IsChildren] navigation_menu_link(
href~ : String,
active? : Bool = false,
target? : @html.Target = Self,
rel? : String,
download? : String,
escape? : Bool = false,
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
let element_attrs = ui_attrs(attrs)
.data_set("slot", "navigation-menu-link")
.data_set("active", ui_bool(active))
if active {
ignore(element_attrs.aria_current("page").data_set("state", "active"))
}
@html.a(
style=ui_styles(
[
UiBoxSizing,
UiFontSans,
UiTransition,
NavigationMenuLinkStyle,
if active {
NavigationMenuLinkActiveStyle
} else {
""
},
],
style,
),
id?,
class?,
title?,
href~,
target~,
rel?,
download?,
attrs=element_attrs,
children,
escape~,
)
}
///|
pub fn navigation_menu_indicator(
scope : NavigationMenuItemScope,
class? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
) -> @html.Html {
@html.span(
style=ui_styles(
[
UiBoxSizing,
NavigationMenuIndicatorStyle,
if scope.open {
"visibility:visible;opacity:1;pointer-events:none"
} else {
"visibility:hidden;opacity:0;pointer-events:none"
},
],
style,
),
class?,
attrs=menu_state_attrs(attrs, "navigation-menu-indicator", scope.open).aria_hidden(
"true",
),
@html.span(
style=[UiBoxSizing, NavigationMenuIndicatorArrowStyle],
@html.nothing,
),
)
}