///|
const HoverCardRootStyle : String = "display:contents"
///|
const HoverCardContentStyle : String = "position:fixed;inset:auto;left:var(--rui-floating-left,auto);top:var(--rui-floating-top,auto);z-index:50;display:block;width:16rem;max-width:calc(100vw - 1rem);transform-origin:var(--rui-floating-transform-origin,center);margin:0;border:1px solid var(--rui-border,oklch(0.922 0 0));border-radius:calc(var(--rui-radius,0.625rem) - 0.125rem);background:var(--rui-popover,oklch(1 0 0));padding:1rem;color:var(--rui-popover-foreground,oklch(0.145 0 0));font-size:0.875rem;line-height:1.25rem;box-shadow:0 4px 6px -1px rgb(0 0 0 / 0.1),0 2px 4px -2px rgb(0 0 0 / 0.1);outline:none;transition:opacity 100ms ease,transform 100ms ease,visibility 100ms ease"
///|
fn hover_card_delay(value : Int) -> Int {
if value < 0 {
0
} else {
value
}
}
///|
fn hover_card_trigger_adjustment_style(variant : ButtonVariant) -> String {
match variant {
Link =>
"height:auto;padding:0;border:0;vertical-align:baseline;text-decoration-line:underline;text-decoration-thickness:from-font"
_ => ""
}
}
///|
/// Render the native link that owns hover-card interaction.
///
/// The stateful `hover_card` root wires hover and focus events. This standalone
/// part exposes the same state, delay, and relationship attributes for custom
/// composition without requiring a stylesheet.
pub fn[C : @html.IsChildren] hover_card_trigger(
content_id~ : String,
href~ : String,
open? : Bool = false,
disabled? : Bool = false,
open_delay? : Int = 600,
close_delay? : Int = 300,
variant? : ButtonVariant = Link,
size? : ButtonSize = Default,
target? : @html.Target = Self,
rel? : String,
download? : String,
escape? : Bool = false,
on_click? : @cmd.Cmd,
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
let resolved_id = id.unwrap_or(content_id + "-trigger")
let actual_open_delay = hover_card_delay(open_delay)
let actual_close_delay = hover_card_delay(close_delay)
let element_attrs = popup_state_attrs(attrs, "hover-card-trigger", open)
.data_set("variant", button_variant_value(variant))
.data_set("size", button_size_value(size))
.data_set("delay", "\{actual_open_delay}")
.data_set("close-delay", "\{actual_close_delay}")
.aria_expanded(ui_bool(open))
.aria_controls(content_id)
if open {
ignore(element_attrs.data_set("popup-open", ""))
}
if disabled {
ignore(
element_attrs.aria_disabled("true").tabindex(-1).data_set("disabled", ""),
)
}
if !disabled && on_click is Some(command) {
ignore(element_attrs.on_click(_ => command))
}
@html.a(
style=ui_styles(
[
UiBoxSizing,
UiFontSans,
UiTextRendering,
UiTransition,
ButtonBaseStyle,
button_variant_style(variant),
button_size_style(size),
hover_card_trigger_adjustment_style(variant),
ui_disabled_style(disabled),
popup_anchor_style(resolved_id),
],
style,
),
id=resolved_id,
class?,
title?,
href~,
target~,
rel?,
download?,
attrs=element_attrs,
children,
escape~,
)
}
///|
/// Render the shadcn Vega hover-card surface on the native Popover top layer.
///
/// `popover="auto"` supplies browser Escape and light-dismiss behavior. The
/// stateful root synchronizes those native changes back into Rabbita state.
pub fn[C : @html.IsChildren] hover_card_content(
id~ : String,
trigger_id~ : String,
open? : Bool = false,
side? : PopupSide = Bottom,
align? : PopupAlign = Center,
side_offset? : Int = 4,
align_offset? : Int = 0,
aria_labelledby? : String,
aria_describedby? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
let element_attrs = popup_state_attrs(attrs, "hover-card-content", open)
.popover("auto")
.tabindex(-1)
.data_set("initial-focus", "false")
.data_set("restore-focus", "false")
.data_set("trigger-id", 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("collision-padding", "0")
.data_set("sticky", "partial")
if aria_labelledby is Some(labelledby) {
ignore(element_attrs.aria_labelledby(labelledby))
}
if aria_describedby is Some(describedby) {
ignore(element_attrs.aria_describedby(describedby))
}
@html.div(
style=ui_styles(
[
UiBoxSizing,
UiFontSans,
UiTextRendering,
HoverCardContentStyle,
PopupTransition100Style,
popup_floating_anchor_style(
trigger_id, side, align, side_offset, align_offset,
),
popup_state_style(open),
],
style,
),
id~,
class?,
title?,
attrs=element_attrs,
children,
)
}
///|
#cfg(target="js")
priv struct HoverCardEventBinding {
root : @dom.Element
dispatch : Ref[(Int, Int) -> Unit]
}
///|
#cfg(target="js")
let hover_card_event_bindings : Map[String, HoverCardEventBinding] = Map([])
///|
#cfg(target="js")
fn hover_card_config(
trigger : @dom.Element,
root : @dom.Element,
trigger_name : String,
root_name : String,
fallback : Int,
) -> Int {
let own = trigger.get_attribute("data-" + trigger_name).unwrap_or("")
let inherited = root.get_attribute("data-" + root_name).unwrap_or("")
let value = if own == "" { inherited } else { own }
let parsed = ui_parse_int_or(value, fallback)
if parsed < 0 {
0
} else {
parsed
}
}
///|
#cfg(target="js")
fn bind_hover_card_events(id : String, dispatch : (Int, Int) -> Unit) -> Bool {
guard ui_element_by_id(id) is Some(popup) else { return false }
guard ui_element_by_id(id + "-trigger") is Some(trigger) else { return false }
guard ui_element_by_id(id + "-root") is Some(root) else { return false }
if !popup.get_is_connected() ||
!trigger.get_is_connected() ||
!root.get_is_connected() {
return false
}
if hover_card_event_bindings.get(id) is Some(binding) &&
binding.root.is_same_node(root.as_node()) {
binding.dispatch.val = dispatch
return true
}
let binding : HoverCardEventBinding = { root, dispatch: Ref(dispatch) }
hover_card_event_bindings[id] = binding
fn open_delay() -> Int {
hover_card_config(trigger, root, "delay", "open-delay", 600)
}
fn close_delay() -> Int {
hover_card_config(trigger, root, "close-delay", "close-delay", 300)
}
fn send(kind : Int, wait : Int) -> Unit {
if !root.has_attribute("data-disabled") {
(binding.dispatch.val)(kind, wait)
}
}
trigger.add_event_listener("mouseenter", _ => send(0, open_delay()))
trigger.add_event_listener("mouseleave", _ => {
let hover_grace = if root.get_attribute("data-hoverable").unwrap_or("") ==
"true" {
100
} else {
0
}
let wait = close_delay()
send(1, if wait < hover_grace { hover_grace } else { wait })
})
trigger.add_event_listener("focus", _ => {
if trigger.matches(":focus-visible") {
send(2, open_delay())
}
})
trigger.add_event_listener("blur", _ => send(3, close_delay()))
trigger.add_event_listener("click", event => {
if root.has_attribute("data-disabled") {
event.prevent_default()
event.stop_propagation()
}
})
popup.add_event_listener("mouseenter", _ => {
if root.get_attribute("data-hoverable").unwrap_or("") == "true" {
send(4, 0)
}
})
popup.add_event_listener("mouseleave", _ => {
if root.get_attribute("data-hoverable").unwrap_or("") == "true" {
send(5, close_delay())
}
})
popup.add_event_listener("focusin", _ => send(6, 0))
popup.add_event_listener("focusout", event => {
let still_inside = if event.to_focus_event() is Some(focus_event) {
if focus_event.related_target() is Some(target) {
ui_element_contains_target(popup, target)
} else {
false
}
} else {
false
}
if !still_inside {
send(7, close_delay())
}
})
true
}
///|
#cfg(target="js")
priv struct HoverCardModel {
open : Bool
trigger_hovered : Bool
trigger_focused : Bool
content_hovered : Bool
content_focused : Bool
generation : Int
} derive(Eq)
///|
#cfg(target="js")
priv enum HoverCardMsg {
HoverCardDomEvent(Int, Int)
HoverCardOpenAfter(Int)
HoverCardCloseAfter(Int)
HoverCardNativeChanged(Bool)
}
///|
#cfg(target="js")
fn hover_card_is_active(model : HoverCardModel) -> Bool {
model.trigger_hovered ||
model.trigger_focused ||
model.content_hovered ||
model.content_focused
}
///|
#cfg(target="js")
fn hover_card_notify_cmd(
on_open_change : @cmd.Emit[Bool]?,
open : Bool,
) -> @cmd.Cmd {
if on_open_change is Some(notify) {
notify(open)
} else {
@cmd.none
}
}
///|
#cfg(target="js")
fn hover_card_commit_open(
id : String,
on_open_change : @cmd.Emit[Bool]?,
model : HoverCardModel,
open : Bool,
) -> (HoverCardModel, @cmd.Cmd) {
if model.open == open {
(model, @cmd.none)
} else {
(
{ ..model, open, },
@cmd.batch([
floating_set_open_cmd(id, open),
hover_card_notify_cmd(on_open_change, open),
]),
)
}
}
///|
#cfg(target="js")
fn hover_card_commit_native(
on_open_change : @cmd.Emit[Bool]?,
model : HoverCardModel,
open : Bool,
) -> (HoverCardModel, @cmd.Cmd) {
if open {
if model.open {
(model, @cmd.none)
} else {
({ ..model, open: true }, hover_card_notify_cmd(on_open_change, true))
}
} else {
let next : HoverCardModel = {
open: false,
trigger_hovered: false,
trigger_focused: false,
content_hovered: false,
content_focused: false,
generation: model.generation + 1,
}
if model.open {
(next, hover_card_notify_cmd(on_open_change, false))
} else {
(next, @cmd.none)
}
}
}
///|
#cfg(target="js")
fn hover_card_bind_events_cmd(
id : String,
emit : @cmd.Emit[HoverCardMsg],
) -> @cmd.Cmd {
@cmd.custom_cmd(kind=@cmd.AfterRender, scheduler => {
ui_after_ready("hover-card-events:" + id, () => {
bind_hover_card_events(id, (kind, wait) => {
scheduler.add(emit(HoverCardDomEvent(kind, wait)))
})
})
})
}
///|
#cfg(target="js")
fn hover_card_open_from_interaction(
id : String,
on_open_change : @cmd.Emit[Bool]?,
emit : @cmd.Emit[HoverCardMsg],
current : HoverCardModel,
next : HoverCardModel,
wait : Int,
) -> (HoverCardModel, @cmd.Cmd) {
if current.open {
(next, @cmd.none)
} else if wait <= 0 {
hover_card_commit_open(id, on_open_change, next, true)
} else {
(next, @cmd.delay(emit(HoverCardOpenAfter(next.generation)), wait))
}
}
///|
#cfg(target="js")
fn hover_card_close_from_interaction(
id : String,
on_open_change : @cmd.Emit[Bool]?,
emit : @cmd.Emit[HoverCardMsg],
next : HoverCardModel,
wait : Int,
) -> (HoverCardModel, @cmd.Cmd) {
if hover_card_is_active(next) || !next.open {
(next, @cmd.none)
} else if wait <= 0 {
hover_card_commit_open(id, on_open_change, next, false)
} else {
(next, @cmd.delay(emit(HoverCardCloseAfter(next.generation)), wait))
}
}
///|
#cfg(target="js")
fn hover_card_update(
id : String,
on_open_change : @cmd.Emit[Bool]?,
emit : @cmd.Emit[HoverCardMsg],
msg : HoverCardMsg,
current : HoverCardModel,
) -> (HoverCardModel, @cmd.Cmd) {
match msg {
HoverCardDomEvent(kind, wait) =>
match kind {
// Trigger hover opens after the configured delay.
0 => {
let next = {
..current,
trigger_hovered: true,
generation: current.generation + 1,
}
hover_card_open_from_interaction(
id, on_open_change, emit, current, next, wait,
)
}
1 => {
let next = {
..current,
trigger_hovered: false,
generation: current.generation + 1,
}
hover_card_close_from_interaction(
id, on_open_change, emit, next, wait,
)
}
// Keyboard focus uses the same opening delay as pointer hover.
2 => {
let next = {
..current,
trigger_focused: true,
generation: current.generation + 1,
}
hover_card_open_from_interaction(
id, on_open_change, emit, current, next, wait,
)
}
3 => {
let next = {
..current,
trigger_focused: false,
generation: current.generation + 1,
}
hover_card_close_from_interaction(
id, on_open_change, emit, next, wait,
)
}
// Hovering or focusing the popup invalidates a pending close.
4 =>
(
{
..current,
content_hovered: true,
generation: current.generation + 1,
},
@cmd.none,
)
5 => {
let next = {
..current,
content_hovered: false,
generation: current.generation + 1,
}
hover_card_close_from_interaction(
id, on_open_change, emit, next, wait,
)
}
6 =>
(
{
..current,
content_focused: true,
generation: current.generation + 1,
},
@cmd.none,
)
7 => {
let next = {
..current,
content_focused: false,
generation: current.generation + 1,
}
hover_card_close_from_interaction(
id, on_open_change, emit, next, wait,
)
}
// Native light-dismiss synchronizes through HoverCardNativeChanged.
_ =>
hover_card_commit_open(
id,
on_open_change,
{
..current,
trigger_hovered: false,
trigger_focused: false,
content_hovered: false,
content_focused: false,
generation: current.generation + 1,
},
false,
)
}
HoverCardOpenAfter(generation) =>
if generation == current.generation && hover_card_is_active(current) {
hover_card_commit_open(id, on_open_change, current, true)
} else {
(current, @cmd.none)
}
HoverCardCloseAfter(generation) =>
if generation == current.generation && !hover_card_is_active(current) {
hover_card_commit_open(id, on_open_change, current, false)
} else {
(current, @cmd.none)
}
HoverCardNativeChanged(open) =>
hover_card_commit_native(on_open_change, current, open)
}
}
///|
fn[T : @html.IsChildren, C : @html.IsChildren] render_hover_card(
id : String,
href : String,
target : @html.Target,
rel : String?,
download : String?,
escape : Bool,
open : Bool,
disabled : Bool,
open_delay : Int,
close_delay : Int,
hoverable : Bool,
side : PopupSide,
align : PopupAlign,
side_offset : Int,
align_offset : Int,
trigger_variant : ButtonVariant,
trigger_size : ButtonSize,
aria_labelledby : String?,
aria_describedby : String?,
attrs : @html.Attrs?,
style : Array[String],
trigger_attrs : @html.Attrs?,
trigger_style : Array[String],
content_attrs : @html.Attrs?,
content_style : Array[String],
on_trigger_click : @cmd.Cmd?,
trigger : T,
content : C,
) -> @html.Html {
let root_id = id + "-root"
let trigger_id = id + "-trigger"
let root_attrs = popup_state_attrs(attrs, "hover-card", open)
.data_set("open-delay", "\{open_delay}")
.data_set("close-delay", "\{close_delay}")
.data_set("hoverable", ui_bool(hoverable))
if disabled {
ignore(root_attrs.data_set("disabled", "").aria_disabled("true"))
}
let link_attrs = trigger_attrs
let popup_attrs = content_attrs
@html.div(
style=ui_styles([UiBoxSizing, HoverCardRootStyle], style),
id=root_id,
attrs=root_attrs,
[
hover_card_trigger(
content_id=id,
href~,
open~,
disabled~,
open_delay~,
close_delay~,
variant=trigger_variant,
size=trigger_size,
target~,
rel?,
download?,
escape~,
on_click?=on_trigger_click,
id=trigger_id,
attrs?=link_attrs,
style=trigger_style,
trigger,
),
hover_card_content(
id~,
trigger_id~,
open~,
side~,
align~,
side_offset~,
align_offset~,
aria_labelledby?,
aria_describedby?,
attrs?=popup_attrs,
style=content_style,
content,
),
],
)
}
///|
/// Build a black-box hover card with Rabbita-owned incremental state.
///
/// Pointer hover, keyboard focus, configurable opening/closing delays,
/// hoverable content, Escape, outside press, native light-dismiss, and floating
/// positioning are handled internally. All Vega styling is inline.
#cfg(target="js")
pub fn[T : @html.IsChildren, C : @html.IsChildren] hover_card(
id~ : String,
href? : String = "#",
target? : @html.Target = Self,
rel? : String,
download? : String,
escape? : Bool = false,
default_open? : Bool = false,
disabled? : Bool = false,
open_delay? : Int = 600,
close_delay? : Int = 300,
hoverable? : Bool = true,
side? : PopupSide = Bottom,
align? : PopupAlign = Center,
side_offset? : Int = 4,
align_offset? : Int = 0,
trigger_variant? : ButtonVariant = Link,
trigger_size? : ButtonSize = Default,
aria_labelledby? : String,
aria_describedby? : String,
on_open_change? : @cmd.Emit[Bool],
on_trigger_click? : @cmd.Cmd,
attrs? : @html.Attrs,
style? : Array[String] = [],
trigger_attrs? : @html.Attrs,
trigger_style? : Array[String] = [],
content_attrs? : @html.Attrs,
content_style? : Array[String] = [],
trigger : T,
content : C,
) -> @rabbita.Val[@html.Html] {
let actual_open_delay = hover_card_delay(open_delay)
let actual_close_delay = hover_card_delay(close_delay)
let initial_open = default_open && !disabled
let initial_model : HoverCardModel = {
open: initial_open,
trigger_hovered: false,
trigger_focused: false,
content_hovered: false,
content_focused: false,
generation: 0,
}
let (model, _) = @rabbita.create_state_with_init(
init=emit => {
let sync = emit.map(value => HoverCardNativeChanged(value))
(
initial_model,
@cmd.batch([
floating_bind_sync_cmd(id, sync),
hover_card_bind_events_cmd(id, emit),
if initial_open {
floating_set_open_cmd(id, true)
} else {
@cmd.none
},
]),
)
},
update=(emit, msg, current) => {
hover_card_update(id, on_open_change, emit, msg, current)
},
)
model.view(model => {
render_hover_card(
id,
href,
target,
rel,
download,
escape,
model.open,
disabled,
actual_open_delay,
actual_close_delay,
hoverable,
side,
align,
side_offset,
align_offset,
trigger_variant,
trigger_size,
aria_labelledby,
aria_describedby,
attrs,
style,
trigger_attrs,
trigger_style,
content_attrs,
content_style,
on_trigger_click,
trigger,
content,
)
})
}
///|
/// Native/SSR fallback: retain the same API and render the normalized initial
/// state as a constant incremental value.
#cfg(not(target="js"))
pub fn[T : @html.IsChildren, C : @html.IsChildren] hover_card(
id~ : String,
href? : String = "#",
target? : @html.Target = Self,
rel? : String,
download? : String,
escape? : Bool = false,
default_open? : Bool = false,
disabled? : Bool = false,
open_delay? : Int = 600,
close_delay? : Int = 300,
hoverable? : Bool = true,
side? : PopupSide = Bottom,
align? : PopupAlign = Center,
side_offset? : Int = 4,
align_offset? : Int = 0,
trigger_variant? : ButtonVariant = Link,
trigger_size? : ButtonSize = Default,
aria_labelledby? : String,
aria_describedby? : String,
on_open_change? : @cmd.Emit[Bool],
on_trigger_click? : @cmd.Cmd,
attrs? : @html.Attrs,
style? : Array[String] = [],
trigger_attrs? : @html.Attrs,
trigger_style? : Array[String] = [],
content_attrs? : @html.Attrs,
content_style? : Array[String] = [],
trigger : T,
content : C,
) -> @rabbita.Val[@html.Html] {
ignore(on_open_change)
@rabbita.Val::constant(
render_hover_card(
id,
href,
target,
rel,
download,
escape,
default_open && !disabled,
disabled,
hover_card_delay(open_delay),
hover_card_delay(close_delay),
hoverable,
side,
align,
side_offset,
align_offset,
trigger_variant,
trigger_size,
aria_labelledby,
aria_describedby,
attrs,
style,
trigger_attrs,
trigger_style,
content_attrs,
content_style,
on_trigger_click,
trigger,
content,
),
)
}