///|
pub(all) enum MessageScrollDirection {
ScrollStart
ScrollEnd
} derive(Debug, Eq)
///|
priv struct MessageScrollerPosition {
at_start : Bool
at_end : Bool
} derive(Eq)
///|
priv enum MessageScrollerMsg {
SetScrollerPosition(Bool, Bool)
}
///|
/// Opaque render scope owned by `message_scroller`.
struct MessageScrollerScope {
viewport_id : String
position : MessageScrollerPosition
emit : @cmd.Emit[MessageScrollerMsg]
}
///|
#cfg(target="js")
fn message_scroller_initialize_cmd(
viewport_id : String,
scroll_to_end : Bool,
emit : @cmd.Emit[MessageScrollerMsg],
) -> @cmd.Cmd {
@cmd.custom_cmd(kind=@cmd.AfterRender, scheduler => {
ui_after_mount(
viewport_id,
() => {
if @dom.document().get_element_by_id(viewport_id).to_option()
is Some(element) {
if scroll_to_end {
element.set_scroll_top(element.get_scroll_height())
}
let top = element.get_scroll_top()
let height = element.get_bounding_client_rect().get_height()
let total = element.get_scroll_height()
scheduler.add(
emit(SetScrollerPosition(top <= 1.0, top + height >= total - 1.0)),
)
}
},
purpose="message-scroller-initialize",
)
})
}
///|
fn[C : @html.IsChildren] render_message_scroller(
scope : MessageScrollerScope,
id : String?,
class : String?,
title : String?,
attrs : @html.Attrs?,
style : Array[String],
children : (MessageScrollerScope) -> C,
) -> @html.Html {
@html.div(
id?,
class?,
title?,
attrs=ui_attrs(attrs).data_set("slot", "message-scroller"),
style=ui_styles(
[
UiBoxSizing,
UiFontSans,
"position:relative;display:flex;width:100%;height:100%;min-height:0;flex-direction:column;overflow:hidden",
],
style,
),
children(scope),
)
}
///|
#cfg(target="js")
pub fn[C : @html.IsChildren] message_scroller(
id~ : String,
default_at_end? : Bool = true,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : (MessageScrollerScope) -> C,
) -> @rabbita.Val[@html.Html] {
let viewport_id = "\{id}-viewport"
let initial = { at_start: !default_at_end, at_end: default_at_end }
let (position, emit) = @rabbita.create_state_with_init(
init=emit => {
(
initial,
message_scroller_initialize_cmd(viewport_id, default_at_end, emit),
)
},
update=(_, msg, _) => {
(
match msg {
SetScrollerPosition(at_start, at_end) => { at_start, at_end }
},
@cmd.none,
)
},
)
position.view(position => {
render_message_scroller(
{ viewport_id, position, emit },
Some(id),
class,
title,
attrs,
style,
children,
)
})
}
///|
#cfg(not(target="js"))
pub fn[C : @html.IsChildren] message_scroller(
id~ : String,
default_at_end? : Bool = true,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : (MessageScrollerScope) -> C,
) -> @rabbita.Val[@html.Html] {
let position = { at_start: !default_at_end, at_end: default_at_end }
let emit = @cmd.Emit(_ => @cmd.none)
@rabbita.Val::constant(
render_message_scroller(
{ viewport_id: "\{id}-viewport", position, emit },
Some(id),
class,
title,
attrs,
style,
children,
),
)
}
///|
#cfg(target="js")
fn message_scroller_viewport_attrs(
scope : MessageScrollerScope,
attrs : @html.Attrs?,
) -> @html.Attrs {
ui_attrs(attrs).on_scroll(event => {
if event.current_target().to_option() is Some(target) &&
target.to_element() is Some(element) {
let top = element.get_scroll_top()
let height = element.get_bounding_client_rect().get_height()
let total = element.get_scroll_height()
(scope.emit)(SetScrollerPosition(top <= 1.0, top + height >= total - 1.0))
} else {
@cmd.none
}
})
}
///|
#cfg(not(target="js"))
fn message_scroller_viewport_attrs(
_ : MessageScrollerScope,
attrs : @html.Attrs?,
) -> @html.Attrs {
ui_attrs(attrs)
}
///|
pub fn[C : @html.IsChildren] message_scroller_viewport(
scope~ : MessageScrollerScope,
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
let element_attrs = message_scroller_viewport_attrs(scope, attrs)
.data_set("slot", "message-scroller-viewport")
.data_set("at-start", ui_bool(scope.position.at_start))
.data_set("at-end", ui_bool(scope.position.at_end))
.tabindex(0)
@html.div(
id=id.unwrap_or(scope.viewport_id),
class?,
title?,
attrs=element_attrs,
style=ui_styles(
[
UiBoxSizing,
"width:100%;height:100%;min-width:0;min-height:0;overflow-y:auto;overscroll-behavior:contain;scrollbar-gutter:stable;scrollbar-width:thin;contain:content;outline-offset:2px",
],
style,
),
children,
)
}
///|
pub fn[C : @html.IsChildren] message_scroller_content(
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
@html.div(
id?,
class?,
title?,
attrs=ui_attrs(attrs).data_set("slot", "message-scroller-content"),
style=ui_styles(
[
"display:flex;height:max-content;min-height:100%;flex-direction:column;gap:2rem",
],
style,
),
children,
)
}
///|
pub fn[C : @html.IsChildren] message_scroller_item(
scroll_anchor? : Bool = false,
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
@html.div(
id?,
class?,
title?,
attrs=ui_attrs(attrs)
.data_set("slot", "message-scroller-item")
.data_set("scroll-anchor", ui_bool(scroll_anchor)),
style=ui_styles(
[
"min-width:0;flex-shrink:0;content-visibility:auto;contain-intrinsic-size:auto 10rem",
if scroll_anchor {
"overflow-anchor:auto"
} else {
"overflow-anchor:none"
},
],
style,
),
children,
)
}
///|
pub fn[C : @html.IsChildren] message_scroller_button(
scope~ : MessageScrollerScope,
direction? : MessageScrollDirection = ScrollEnd,
label? : String,
variant? : ButtonVariant = Secondary,
size? : ButtonSize = IconSm,
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
let end_ = direction is ScrollEnd
let active = if end_ {
!scope.position.at_end
} else {
!scope.position.at_start
}
let direction_value = if end_ { "end" } else { "start" }
let button_attrs = ui_attrs(attrs)
.data_set("slot", "message-scroller-button")
.data_set("direction", direction_value)
.data_set("active", ui_bool(active))
if label is Some(label) {
ignore(button_attrs.aria_label(label))
}
button(
variant~,
size~,
disabled=!active,
slot="message-scroller-button",
on_click=ui_scroll_id(scope.viewport_id, end_),
id?,
class?,
title?,
attrs=button_attrs,
style=ui_styles(
[
"position:absolute;left:50%;z-index:10;transform:translate(-50%,var(--rui-message-scroller-shift,0)) scale(var(--rui-message-scroller-scale,1));transition:transform 200ms cubic-bezier(0.23,1,0.32,1),opacity 200ms cubic-bezier(0.23,1,0.32,1);--rui-button-base-border:var(--rui-border,oklch(0.922 0 0));--rui-button-base-bg:var(--rui-background,white);--rui-button-base-fg:var(--rui-foreground,oklch(0.145 0 0))",
if end_ {
"bottom:1rem"
} else {
"top:1rem"
},
if active {
"--rui-message-scroller-shift:0;--rui-message-scroller-scale:1;opacity:1"
} else if end_ {
"--rui-message-scroller-shift:100%;--rui-message-scroller-scale:0.95;opacity:0;transition-duration:400ms;transition-timing-function:cubic-bezier(0.7,0,0.84,0)"
} else {
"--rui-message-scroller-shift:-100%;--rui-message-scroller-scale:0.95;opacity:0;transition-duration:400ms;transition-timing-function:cubic-bezier(0.7,0,0.84,0)"
},
],
style,
),
children,
)
}