///|
pub(all) enum CarouselOrientation {
CarouselHorizontal
CarouselVertical
} derive(Debug, Eq)
///|
/// Opaque state passed from `carousel` to its compound content, item, and
/// previous/next controls.
struct CarouselScope {
index : Int
count : Int
max_index : Int
loop_ : Bool
orientation : CarouselOrientation
emit : @cmd.Emit[Int]
}
///|
#cfg(target="js")
priv struct CarouselModel {
index : Int
max_index : Int
} derive(Eq)
///|
#cfg(target="js")
priv enum CarouselMsg {
CarouselSetIndex(Int)
CarouselMeasured(Int)
}
///|
const CarouselRootStyle : String = "position:relative;display:block;width:100%;min-width:0;color:var(--rui-foreground,oklch(0.145 0 0))"
///|
const CarouselScopeStyle : String = "display:contents"
///|
const CarouselViewportStyle : String = "position:relative;width:100%;min-width:0;overflow:hidden;touch-action:var(--rui-carousel-touch-action,pan-y pinch-zoom);cursor:var(--rui-carousel-cursor,grab);user-select:var(--rui-carousel-user-select,auto);-webkit-user-select:var(--rui-carousel-user-select,auto)"
///|
const CarouselViewportVerticalStyle : String = "height:var(--rui-carousel-height,12rem);--rui-carousel-touch-action:pan-x pinch-zoom"
///|
const CarouselTrackStyle : String = "display:flex;min-width:0;will-change:transform;transition:var(--rui-carousel-track-transition,transform 300ms cubic-bezier(0.22,1,0.36,1))"
///|
const CarouselTrackHorizontalStyle : String = "flex-direction:row;margin-inline-start:-1rem"
///|
const CarouselTrackVerticalStyle : String = "height:100%;flex-direction:column;margin-top:-1rem"
///|
const CarouselItemStyle : String = "position:relative;min-width:0;flex-grow:0;flex-shrink:0;flex-basis:100%"
///|
const CarouselItemHorizontalStyle : String = "padding-inline-start:1rem"
///|
const CarouselItemVerticalStyle : String = "min-height:100%;padding-top:1rem"
///|
const CarouselControlStyle : String = "position:absolute;z-index:2;border-radius:9999px"
///|
const CarouselPreviousHorizontalStyle : String = "top:50%;inset-inline-start:var(--rui-carousel-control-inline-offset,-3rem);transform:translateY(-50%)"
///|
const CarouselNextHorizontalStyle : String = "top:50%;inset-inline-end:var(--rui-carousel-control-inline-offset,-3rem);transform:translateY(-50%)"
///|
const CarouselPreviousVerticalStyle : String = "top:var(--rui-carousel-control-block-offset,-3rem);left:50%;transform:translateX(-50%)"
///|
const CarouselNextVerticalStyle : String = "bottom:var(--rui-carousel-control-block-offset,-3rem);left:50%;transform:translateX(-50%)"
///|
const CarouselChevronBoxStyle : String = "display:inline-flex;width:1rem;height:1rem;flex-shrink:0;align-items:center;justify-content:center"
///|
const CarouselChevronStyle : String = "display:block;width:0.5rem;height:0.5rem;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform-origin:center"
///|
const CarouselChevronLeftStyle : String = "transform:rotate(135deg)"
///|
const CarouselChevronRightStyle : String = "transform:rotate(-45deg)"
///|
const CarouselChevronUpStyle : String = "transform:rotate(225deg)"
///|
const CarouselChevronDownStyle : String = "transform:rotate(45deg)"
///|
const CarouselScreenReaderStyle : String = "position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap"
///|
fn carousel_orientation_value(orientation : CarouselOrientation) -> String {
match orientation {
CarouselHorizontal => "horizontal"
CarouselVertical => "vertical"
}
}
///|
fn carousel_count(count : Int) -> Int {
if count < 0 {
0
} else {
count
}
}
///|
fn carousel_index(index : Int, count : Int) -> Int {
if count <= 0 || index < 0 {
0
} else if index >= count {
count - 1
} else {
index
}
}
///|
fn carousel_previous_index(scope : CarouselScope) -> Int {
if scope.max_index <= 0 {
scope.index
} else if scope.index > 0 {
scope.index - 1
} else if scope.loop_ {
scope.max_index
} else {
scope.index
}
}
///|
fn carousel_next_index(scope : CarouselScope) -> Int {
if scope.max_index <= 0 {
scope.index
} else if scope.index < scope.max_index {
scope.index + 1
} else if scope.loop_ {
0
} else {
scope.index
}
}
///|
/// Return the active zero-based index from a compound carousel scope.
pub fn carousel_active_index(scope : CarouselScope) -> Int {
scope.index
}
///|
/// Return the normalized number of slides from a compound carousel scope.
pub fn carousel_slide_count(scope : CarouselScope) -> Int {
scope.count
}
///|
pub fn carousel_can_previous(scope : CarouselScope) -> Bool {
scope.max_index > 0 && (scope.loop_ || scope.index > 0)
}
///|
pub fn carousel_can_next(scope : CarouselScope) -> Bool {
scope.max_index > 0 && (scope.loop_ || scope.index < scope.max_index)
}
///|
fn carousel_track_transform(scope : CarouselScope) -> String {
let offset = scope.index * 100
match scope.orientation {
CarouselHorizontal =>
"--rui-carousel-ltr-offset:-\{offset}%;--rui-carousel-rtl-offset:\{offset}%;transform:translate3d(var(--rui-carousel-offset,var(--rui-carousel-ltr-offset)),0,0)"
CarouselVertical =>
"--rui-carousel-block-offset:-\{offset}%;transform:translate3d(0,var(--rui-carousel-offset,var(--rui-carousel-block-offset)),0)"
}
}
///|
fn carousel_track_orientation_style(
orientation : CarouselOrientation,
) -> String {
match orientation {
CarouselHorizontal => CarouselTrackHorizontalStyle
CarouselVertical => CarouselTrackVerticalStyle
}
}
///|
fn carousel_viewport_orientation_style(
orientation : CarouselOrientation,
) -> String {
match orientation {
CarouselHorizontal => ""
CarouselVertical => CarouselViewportVerticalStyle
}
}
///|
fn carousel_item_orientation_style(orientation : CarouselOrientation) -> String {
match orientation {
CarouselHorizontal => CarouselItemHorizontalStyle
CarouselVertical => CarouselItemVerticalStyle
}
}
///|
fn carousel_control_position_style(
orientation : CarouselOrientation,
previous : Bool,
) -> String {
match (orientation, previous) {
(CarouselHorizontal, true) => CarouselPreviousHorizontalStyle
(CarouselHorizontal, false) => CarouselNextHorizontalStyle
(CarouselVertical, true) => CarouselPreviousVerticalStyle
(CarouselVertical, false) => CarouselNextVerticalStyle
}
}
///|
fn carousel_control_chevron_style(
orientation : CarouselOrientation,
previous : Bool,
) -> String {
match (orientation, previous) {
(CarouselHorizontal, true) => CarouselChevronLeftStyle
(CarouselHorizontal, false) => CarouselChevronRightStyle
(CarouselVertical, true) => CarouselChevronUpStyle
(CarouselVertical, false) => CarouselChevronDownStyle
}
}
///|
#cfg(target="js")
fn carousel_key_step(event : @dom.KeyboardEvent, orientation : String) -> Int {
if event.alt_key() ||
event.ctrl_key() ||
event.meta_key() ||
event.shift_key() {
return 0
}
if event.target().to_element() is Some(target) &&
target.closest("input,textarea,select,[contenteditable=\"true\"]")
is Some(_) {
return 0
}
if orientation == "vertical" {
if event.key() == "ArrowUp" {
-1
} else if event.key() == "ArrowDown" {
1
} else {
0
}
} else {
let rtl = if event.current_target().to_option() is Some(current_target) &&
current_target.to_element() is Some(element) {
ui_element_is_rtl(element)
} else {
false
}
if event.key() == "ArrowLeft" {
if rtl {
1
} else {
-1
}
} else if event.key() == "ArrowRight" {
if rtl {
-1
} else {
1
}
} else {
0
}
}
}
///|
#cfg(target="js")
let carousel_id_counter : Ref[Int] = Ref(0)
///|
#cfg(target="js")
fn carousel_next_id() -> String {
carousel_id_counter.val += 1
"rui-carousel-\{carousel_id_counter.val}"
}
///|
#cfg(target="js")
priv struct CarouselLayoutState {
root : @dom.Element
mut viewport : @dom.Element
mut track : @dom.Element
mut items : Array[@dom.Element]
mut notify : (Int) -> Unit
mut previous_index : Int?
mut offset : Double?
mut reported_max : Int?
mut observer : @dom.ResizeObserver?
mut transition_generation : Int
}
///|
#cfg(target="js")
let carousel_layout_states : Array[CarouselLayoutState] = []
///|
#cfg(target="js")
fn carousel_element_owned_by(
root : @dom.Element,
element : @dom.Element,
) -> Bool {
element.closest("[data-slot=\"carousel\"]") is Some(owner) &&
owner.is_same_node(root.as_node())
}
///|
#cfg(target="js")
fn carousel_owned_elements(
root : @dom.Element,
selector : String,
) -> Array[@dom.Element] {
root
.query_selector_all(selector)
.filter(element => carousel_element_owned_by(root, element))
}
///|
#cfg(target="js")
fn carousel_owned_element(
root : @dom.Element,
selector : String,
) -> @dom.Element? {
carousel_owned_elements(root, selector).get(0)
}
///|
#cfg(target="js")
fn carousel_layout_state(root : @dom.Element) -> CarouselLayoutState? {
for state in carousel_layout_states {
if state.root.is_same_node(root.as_node()) {
return Some(state)
}
}
None
}
///|
#cfg(target="js")
fn carousel_remove_layout_state(root : @dom.Element) -> Unit {
for index, state in carousel_layout_states {
if state.root.is_same_node(root.as_node()) {
if state.observer is Some(observer) {
observer.disconnect()
}
ignore(carousel_layout_states.remove(index))
return
}
}
}
///|
#cfg(target="js")
fn carousel_item_dimension(item : @dom.Element, vertical : Bool) -> Double {
let offset = if item.to_html_element() is Some(html) {
if vertical {
html.get_offset_height()
} else {
html.get_offset_width()
}
} else {
0.0
}
if offset > 0.0 {
offset
} else {
let rect = item.get_bounding_client_rect()
(if vertical { rect.get_height() } else { rect.get_width() }).max(0.0)
}
}
///|
#cfg(target="js")
fn carousel_item_offset(item : @dom.Element, vertical : Bool) -> Double {
if item.to_html_element() is Some(html) {
if vertical {
html.get_offset_top()
} else {
html.get_offset_left()
}
} else {
0.0
}
}
///|
#cfg(target="js")
fn carousel_restore_transition(
state : CarouselLayoutState,
generation : Int,
transition : String,
) -> Unit {
if state.transition_generation != generation ||
state.track.to_html_element() is None {
return
}
guard state.track.to_html_element() is Some(track) else { return }
let style = track.get_style()
if transition == "" {
ignore(style.remove_property("transition"))
} else {
style.set_property("transition", transition)
}
}
///|
#cfg(target="js")
fn carousel_set_control_state(control : @dom.Element, enabled : Bool) -> Unit {
control.set_attribute("aria-disabled", ui_bool(!enabled))
control.set_attribute(
"data-state",
if enabled {
"enabled"
} else {
"disabled"
},
)
if enabled {
control.remove_attribute("disabled")
control.remove_attribute("data-disabled")
} else {
control.set_attribute("disabled", "")
control.set_attribute("data-disabled", "")
}
}
///|
#cfg(target="js")
fn carousel_sync_layout_state(
state : CarouselLayoutState,
suppress_initial : Bool,
) -> Int {
if state.items.length() == 0 || state.track.to_html_element() is None {
return 0
}
guard state.track.to_html_element() is Some(track_html) else { return 0 }
let vertical = state.root.get_attribute("data-orientation").unwrap_or("") ==
"vertical"
let rtl = !vertical && ui_element_is_rtl(state.viewport)
let first = state.items[0]
let first_offset = carousel_item_offset(first, vertical)
let first_right = carousel_item_offset(first, false) +
carousel_item_dimension(first, false)
let starts : Array[Double] = []
let sizes : Array[Double] = []
for item in state.items {
let start = if vertical {
(carousel_item_offset(item, true) - first_offset).max(0.0)
} else if rtl {
(first_right -
(carousel_item_offset(item, false) + carousel_item_dimension(item, false))).max(
0.0,
)
} else {
(carousel_item_offset(item, false) - carousel_item_offset(first, false)).max(
0.0,
)
}
starts.push(start)
sizes.push(carousel_item_dimension(item, vertical))
}
let mut content_span = 0.0
for index, start in starts {
content_span = content_span.max(start + sizes[index])
}
let viewport_size = (if vertical {
state.viewport.get_client_height()
} else {
state.viewport.get_client_width()
}).max(0.0)
let max_travel = (content_span - viewport_size).max(0.0)
let mut max_index = 0
if max_travel > 0.5 {
max_index = state.items.length() - 1
for index, start in starts {
if start >= max_travel - 0.5 {
max_index = index
break
}
}
}
let requested = ui_parse_int_or(
state.root.get_attribute("data-active-index").unwrap_or(""),
0,
).clamp(min=0, max=state.items.length() - 1)
let index = requested.min(max_index)
let travel = starts[index].clamp(min=0.0, max=max_travel)
let offset = if travel == 0.0 { 0.0 } else if rtl { travel } else { -travel }
let loop_ = state.root.get_attribute("data-loop").unwrap_or("") == "true"
let wrapped = if state.previous_index is Some(previous) {
loop_ &&
max_index > 0 &&
(
(previous == 0 && index == max_index) ||
(previous == max_index && index == 0)
)
} else {
false
}
if (suppress_initial && state.previous_index is None) || wrapped {
let style = track_html.get_style()
let transition = style.get_property_value("transition")
state.transition_generation += 1
let generation = state.transition_generation
style.set_property("transition", "none")
ignore(
@dom.window().request_animation_frame(_ => {
carousel_restore_transition(state, generation, transition)
}),
)
}
track_html.get_style().set_property("--rui-carousel-offset", "\{offset}px")
state.offset = Some(offset)
state.previous_index = Some(index)
state.root.set_attribute("data-max-snap-index", "\{max_index}")
for item_index, item in state.items {
let visible = sizes[item_index] > 0.0 &&
starts[item_index] + sizes[item_index] > travel + 0.5 &&
starts[item_index] < travel + viewport_size - 0.5
item.set_attribute("data-visible", ui_bool(visible))
if visible {
item.remove_attribute("aria-hidden")
item.remove_attribute("inert")
} else {
item.set_attribute("aria-hidden", "true")
item.set_attribute("inert", "")
}
}
if carousel_owned_element(state.root, "[data-slot=\"carousel-previous\"]")
is Some(previous) {
carousel_set_control_state(previous, max_index > 0 && (loop_ || index > 0))
}
if carousel_owned_element(state.root, "[data-slot=\"carousel-next\"]")
is Some(next) {
carousel_set_control_state(
next,
max_index > 0 && (loop_ || index < max_index),
)
}
max_index
}
///|
#cfg(target="js")
fn carousel_report_layout(state : CarouselLayoutState, max_index : Int) -> Unit {
if state.reported_max != Some(max_index) {
state.reported_max = Some(max_index)
(state.notify)(max_index)
}
}
///|
#cfg(target="js")
fn carousel_layout_targets_changed(
state : CarouselLayoutState,
viewport : @dom.Element,
track : @dom.Element,
items : Array[@dom.Element],
) -> Bool {
if !state.viewport.is_same_node(viewport.as_node()) ||
!state.track.is_same_node(track.as_node()) ||
state.items.length() != items.length() {
return true
}
for index, item in items {
if !state.items[index].is_same_node(item.as_node()) {
return true
}
}
false
}
///|
#cfg(target="js")
fn carousel_observe_layout(state : CarouselLayoutState) -> Unit {
if state.observer is None {
state.observer = Some(
@dom.ResizeObserver::new((_, _) => {
if !state.root.get_is_connected() {
carousel_remove_layout_state(state.root)
} else {
carousel_report_layout(
state,
carousel_sync_layout_state(state, false),
)
}
}),
)
}
if state.observer is Some(observer) {
observer.observe(state.viewport)
observer.observe(state.track)
for item in state.items {
observer.observe(item)
}
}
}
///|
#cfg(target="js")
fn carousel_layout_offset(track : @dom.Element) -> Double? {
for state in carousel_layout_states {
if state.track.is_same_node(track.as_node()) {
return state.offset
}
}
None
}
///|
#cfg(target="js")
fn carousel_sync_layout(id : String, notify : (Int) -> Unit) -> Unit {
guard ui_element_by_id(id) is Some(root) else { return }
guard root.get_attribute("data-slot").unwrap_or("") == "carousel" else {
return
}
guard carousel_owned_element(root, "[data-slot=\"carousel-content\"]")
is Some(viewport) else {
return
}
guard carousel_owned_element(root, "[data-slot=\"carousel-track\"]")
is Some(track) else {
return
}
let items = carousel_owned_elements(root, "[data-slot=\"carousel-item\"]")
if items.length() == 0 {
return
}
let state = if carousel_layout_state(root) is Some(state) {
if carousel_layout_targets_changed(state, viewport, track, items) &&
state.observer is Some(observer) {
observer.disconnect()
state.observer = None
}
state.viewport = viewport
state.track = track
state.items = items
state.notify = notify
state
} else {
let state : CarouselLayoutState = {
root,
viewport,
track,
items,
notify,
previous_index: None,
offset: None,
reported_max: None,
observer: None,
transition_generation: 0,
}
carousel_layout_states.push(state)
state
}
carousel_report_layout(state, carousel_sync_layout_state(state, true))
carousel_observe_layout(state)
}
///|
#cfg(target="js")
fn carousel_sync_layout_cmd(
id : String,
emit : @cmd.Emit[CarouselMsg],
) -> @cmd.Cmd {
@cmd.custom_cmd(kind=@cmd.AfterRender, scheduler => {
ui_after_mount(
id,
() => {
carousel_sync_layout(id, max_index => {
scheduler.add(emit(CarouselMeasured(max_index)))
})
},
purpose="carousel-layout",
)
})
}
///|
#cfg(target="js")
priv struct CarouselPointerState {
viewport : @dom.Element
track : @dom.Element
pointer_id : Int
horizontal : Bool
rtl : Bool
can_previous : Bool
can_next : Bool
resist_previous : Bool
resist_next : Bool
start_x : Int
start_y : Int
start_time : Double
size : Double
base_offset : Double?
base_percent : Double
mut active : Bool
original_transform : String
original_transition : String
original_cursor : String
original_user_select : String
mut suppress_click : @dom.Listener?
}
///|
#cfg(target="js")
let carousel_pointer_states : Array[CarouselPointerState] = []
///|
#cfg(target="js")
fn carousel_pointer_state(viewport : @dom.Element) -> CarouselPointerState? {
for state in carousel_pointer_states {
if state.viewport.is_same_node(viewport.as_node()) {
return Some(state)
}
}
None
}
///|
#cfg(target="js")
fn carousel_remove_pointer_state(viewport : @dom.Element) -> Unit {
for index, state in carousel_pointer_states {
if state.viewport.is_same_node(viewport.as_node()) {
ignore(carousel_pointer_states.remove(index))
return
}
}
}
///|
#cfg(target="js")
fn carousel_restore_style_property(
style : @dom.CSSStyleDeclaration,
name : String,
value : String,
) -> Unit {
if value == "" {
ignore(style.remove_property(name))
} else {
style.set_property(name, value)
}
}
///|
#cfg(target="js")
fn carousel_remove_click_suppression(state : CarouselPointerState) -> Unit {
if state.suppress_click is Some(listener) {
state.viewport.remove_event_listener_with_options(
"click",
listener,
capture=true,
)
state.suppress_click = None
}
}
///|
#cfg(target="js")
fn carousel_install_click_suppression(state : CarouselPointerState) -> Unit {
if state.suppress_click is Some(_) {
return
}
let listener : @dom.Listener = click => {
click.prevent_default()
click.stop_propagation()
carousel_remove_click_suppression(state)
}
state.suppress_click = Some(listener)
state.viewport.add_event_listener_with_options(
"click",
listener,
capture=true,
)
}
///|
#cfg(target="js")
fn carousel_release_pointer_state(
state : CarouselPointerState,
keep_click_suppression : Bool,
) -> Unit {
if state.track.to_html_element() is Some(track) {
let style = track.get_style()
carousel_restore_style_property(
style,
"transform",
state.original_transform,
)
carousel_restore_style_property(
style,
"transition",
state.original_transition,
)
}
if state.viewport.to_html_element() is Some(viewport) {
let style = viewport.get_style()
carousel_restore_style_property(
style,
"--rui-carousel-cursor",
state.original_cursor,
)
carousel_restore_style_property(
style,
"--rui-carousel-user-select",
state.original_user_select,
)
}
state.viewport.remove_attribute("data-dragging")
if state.viewport.has_pointer_capture(state.pointer_id) {
state.viewport.release_pointer_capture(state.pointer_id)
}
carousel_remove_pointer_state(state.viewport)
if keep_click_suppression && state.suppress_click is Some(_) {
ignore(
@dom.window().set_timeout(
() => carousel_remove_click_suppression(state),
0,
),
)
} else {
carousel_remove_click_suppression(state)
}
}
///|
#cfg(target="js")
fn carousel_pointer_start(
event : @dom.MouseEvent,
orientation : String,
index : Int,
can_previous : Bool,
can_next : Bool,
) -> Bool {
guard event.to_pointer_event() is Some(pointer) else { return false }
if pointer.get_default_prevented() ||
!pointer.get_is_primary() ||
(pointer.get_pointer_type() == "mouse" && pointer.get_button() != 0) ||
(!can_previous && !can_next) {
return false
}
guard pointer.current_target().to_option() is Some(current_target) else {
return false
}
guard current_target.to_element() is Some(viewport) else { return false }
guard viewport.to_html_element() is Some(viewport_html) else { return false }
guard viewport.query_selector("[data-slot=\"carousel-track\"]") is Some(track) else {
return false
}
guard track.to_html_element() is Some(track_html) else { return false }
if pointer.target().to_element() is Some(target) &&
target.closest("input,textarea,select,[contenteditable=\"true\"]")
is Some(_) {
return false
}
if carousel_pointer_state(viewport) is Some(previous) {
carousel_release_pointer_state(previous, false)
}
let horizontal = orientation == "horizontal"
let rtl = horizontal && ui_element_is_rtl(viewport)
let root = viewport.closest("[data-slot=\"carousel\"]")
let max_index = if root is Some(root) {
ui_parse_int_or(
root.get_attribute("data-max-snap-index").unwrap_or(""),
(ui_parse_int_or(root.get_attribute("data-count").unwrap_or(""), 1) - 1).max(
0,
),
)
} else {
0
}
let loop_ = root is Some(root) &&
root.get_attribute("data-loop").unwrap_or("") == "true"
let track_style = track_html.get_style()
let viewport_style = viewport_html.get_style()
let state : CarouselPointerState = {
viewport,
track,
pointer_id: pointer.get_pointer_id(),
horizontal,
rtl,
can_previous,
can_next,
resist_previous: loop_ && index <= 0,
resist_next: loop_ && index >= max_index,
start_x: pointer.get_client_x(),
start_y: pointer.get_client_y(),
start_time: pointer.get_time_stamp(),
size: if horizontal {
viewport.get_client_width()
} else {
viewport.get_client_height()
},
base_offset: carousel_layout_offset(track),
base_percent: (index * 100 * (if horizontal && rtl { 1 } else { -1 })).to_double(),
active: false,
original_transform: track_style.get_property_value("transform"),
original_transition: track_style.get_property_value("transition"),
original_cursor: viewport_style.get_property_value("--rui-carousel-cursor"),
original_user_select: viewport_style.get_property_value(
"--rui-carousel-user-select",
),
suppress_click: None,
}
carousel_pointer_states.push(state)
viewport.set_pointer_capture(state.pointer_id)
true
}
///|
#cfg(target="js")
fn carousel_pointer_move(event : @dom.MouseEvent) -> Bool {
guard event.to_pointer_event() is Some(pointer) else { return false }
guard pointer.current_target().to_option() is Some(current_target) else {
return false
}
guard current_target.to_element() is Some(viewport) else { return false }
guard carousel_pointer_state(viewport) is Some(state) else { return false }
guard state.pointer_id == pointer.get_pointer_id() else { return false }
guard viewport.to_html_element() is Some(viewport_html) else { return false }
guard state.track.to_html_element() is Some(track_html) else {
carousel_release_pointer_state(state, false)
return false
}
let primary = if state.horizontal {
pointer.get_client_x() - state.start_x
} else {
pointer.get_client_y() - state.start_y
}
let cross = if state.horizontal {
pointer.get_client_y() - state.start_y
} else {
pointer.get_client_x() - state.start_x
}
if !state.active {
if primary.abs() < 6 || primary.abs() <= cross.abs() {
return false
}
state.active = true
carousel_install_click_suppression(state)
viewport.set_attribute("data-dragging", "true")
viewport_html.get_style().set_property("--rui-carousel-cursor", "grabbing")
viewport_html.get_style().set_property("--rui-carousel-user-select", "none")
}
let toward_next = if state.horizontal {
if state.rtl {
primary > 0
} else {
primary < 0
}
} else {
primary < 0
}
let blocked = if toward_next {
!state.can_next || state.resist_next
} else {
!state.can_previous || state.resist_previous
}
let visual = if blocked {
primary.to_double() * 0.35
} else {
primary.to_double()
}
let base = if state.base_offset is Some(offset) {
"\{offset}px"
} else {
"\{state.base_percent}%"
}
let transform = if state.horizontal {
"translate3d(calc(\{base} + \{visual}px),0,0)"
} else {
"translate3d(0,calc(\{base} + \{visual}px),0)"
}
let track_style = track_html.get_style()
track_style.set_property("transition", "none")
track_style.set_property("transform", transform)
true
}
///|
#cfg(target="js")
fn carousel_pointer_end(event : @dom.MouseEvent) -> Int {
guard event.to_pointer_event() is Some(pointer) else { return 0 }
guard pointer.current_target().to_option() is Some(current_target) else {
return 0
}
guard current_target.to_element() is Some(viewport) else { return 0 }
guard carousel_pointer_state(viewport) is Some(state) else { return 0 }
guard state.pointer_id == pointer.get_pointer_id() else { return 0 }
let primary = if state.horizontal {
pointer.get_client_x() - state.start_x
} else {
pointer.get_client_y() - state.start_y
}
let elapsed = (pointer.get_time_stamp() - state.start_time).max(1.0)
let threshold = (state.size * 0.12).max(24.0).min(64.0)
let qualifies = state.active &&
(
primary.abs().to_double() >= threshold ||
(primary.abs() >= 12 && primary.abs().to_double() / elapsed >= 0.45)
)
let toward_next = if state.horizontal {
if state.rtl {
primary > 0
} else {
primary < 0
}
} else {
primary < 0
}
let mut step = if qualifies { if toward_next { 1 } else { -1 } } else { 0 }
if (step > 0 && !state.can_next) || (step < 0 && !state.can_previous) {
step = 0
}
let dragged = state.active
carousel_release_pointer_state(state, dragged)
if step != 0 {
step
} else if dragged {
2
} else {
0
}
}
///|
#cfg(target="js")
fn carousel_pointer_cancel(event : @dom.MouseEvent) -> Unit {
guard event.to_pointer_event() is Some(pointer) else { return }
guard pointer.current_target().to_option() is Some(current_target) else {
return
}
guard current_target.to_element() is Some(viewport) else { return }
guard carousel_pointer_state(viewport) is Some(state) else { return }
guard state.pointer_id == pointer.get_pointer_id() else { return }
carousel_release_pointer_state(state, false)
}
///|
#cfg(target="js")
fn carousel_bind_pointer_attrs(
attrs : @html.Attrs,
scope : CarouselScope,
) -> Unit {
ignore(
attrs
.on_pointerdown(event => {
ignore(
carousel_pointer_start(
event,
carousel_orientation_value(scope.orientation),
scope.index,
carousel_can_previous(scope),
carousel_can_next(scope),
),
)
@cmd.none
})
.on_pointermove(event => {
if carousel_pointer_move(event) {
event.prevent_default()
}
@cmd.none
})
.on_pointerup(event => {
let result = carousel_pointer_end(event)
if result != 0 {
event.prevent_default()
}
if result == -1 {
(scope.emit)(carousel_previous_index(scope))
} else if result == 1 {
(scope.emit)(carousel_next_index(scope))
} else {
@cmd.none
}
})
.on_pointercancel(event => {
carousel_pointer_cancel(event)
@cmd.none
}),
)
}
///|
#cfg(not(target="js"))
fn carousel_bind_pointer_attrs(
attrs : @html.Attrs,
scope : CarouselScope,
) -> Unit {
ignore((attrs, scope))
}
///|
#cfg(target="js")
fn carousel_keyboard_attrs(scope : CarouselScope) -> @html.Attrs {
let attrs = @html.Attrs::build().data_set("slot", "carousel-scope")
ignore(
attrs.on_keydown(event => {
let step = carousel_key_step(
event,
carousel_orientation_value(scope.orientation),
)
if step < 0 && carousel_can_previous(scope) {
event.prevent_default()
(scope.emit)(carousel_previous_index(scope))
} else if step > 0 && carousel_can_next(scope) {
event.prevent_default()
(scope.emit)(carousel_next_index(scope))
} else {
@cmd.none
}
}),
)
attrs
}
///|
#cfg(not(target="js"))
fn carousel_keyboard_attrs(scope : CarouselScope) -> @html.Attrs {
ignore(scope)
@html.Attrs::build().data_set("slot", "carousel-scope")
}
///|
fn carousel_status(scope : CarouselScope) -> @html.Html {
let text = if scope.count == 0 {
"No slides"
} else {
"Slide \{scope.index + 1} of \{scope.count}"
}
@html.span(
style=[UiBoxSizing, CarouselScreenReaderStyle],
attrs=@html.Attrs::build()
.data_set("slot", "carousel-status")
.aria_live("polite")
.aria_atomic("true"),
text,
)
}
///|
fn[C : @html.IsChildren] render_carousel(
scope : CarouselScope,
aria_label : String?,
id : String?,
class : String?,
title : String?,
hidden : Bool?,
attrs : @html.Attrs?,
style : Array[String],
children : (CarouselScope) -> C,
) -> @html.Html {
let root_attrs = ui_attrs(attrs)
.data_set("slot", "carousel")
.data_set("orientation", carousel_orientation_value(scope.orientation))
.data_set("active-index", scope.index.to_string())
.data_set("count", scope.count.to_string())
.data_set("max-snap-index", scope.max_index.to_string())
.data_set("loop", ui_bool(scope.loop_))
.role("region")
.aria_roledescription("carousel")
.aria_orientation(carousel_orientation_value(scope.orientation))
if aria_label is Some(label) {
ignore(root_attrs.aria_label(label))
}
@html.div(
style=ui_styles(
[UiBoxSizing, UiFontSans, UiTextRendering, CarouselRootStyle],
style,
),
id?,
class?,
title?,
hidden?,
attrs=root_attrs,
[
@html.div(
style=[UiBoxSizing, CarouselScopeStyle],
attrs=carousel_keyboard_attrs(scope),
children(scope),
),
carousel_status(scope),
],
)
}
///|
/// Render the clipping viewport and translated slide track for a scope.
///
/// Component props style the viewport; `track_attrs` and `track_style` target
/// the moving flex track. All caller attribute values are cloned.
pub fn[C : @html.IsChildren] carousel_content(
scope~ : CarouselScope,
id? : String,
class? : String,
title? : String,
hidden? : Bool,
attrs? : @html.Attrs,
track_attrs? : @html.Attrs,
style? : Array[String] = [],
track_style? : Array[String] = [],
children : C,
) -> @html.Html {
let orientation = carousel_orientation_value(scope.orientation)
let viewport_attrs = ui_attrs(attrs)
.data_set("slot", "carousel-content")
.data_set("orientation", orientation)
.data_set("swipe", "true")
carousel_bind_pointer_attrs(viewport_attrs, scope)
let moving_attrs = ui_attrs(track_attrs)
.data_set("slot", "carousel-track")
.data_set("orientation", orientation)
@html.div(
style=ui_styles(
[
UiBoxSizing,
CarouselViewportStyle,
carousel_viewport_orientation_style(scope.orientation),
],
style,
),
id?,
class?,
title?,
hidden?,
attrs=viewport_attrs,
@html.div(
style=ui_styles(
[
UiBoxSizing,
CarouselTrackStyle,
carousel_track_orientation_style(scope.orientation),
carousel_track_transform(scope),
],
track_style,
),
attrs=moving_attrs,
children,
),
)
}
///|
/// Render one semantic carousel slide.
///
/// Mounted stateful carousels measure the viewport and make only truly clipped
/// slides inert. Static/controlled markup stays conservative because active
/// index alone cannot determine visibility when callers customize item sizes.
pub fn[C : @html.IsChildren] carousel_item(
scope~ : CarouselScope,
index~ : Int,
aria_label? : String,
id? : String,
class? : String,
title? : String,
hidden? : Bool,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
let active = index == scope.index && index >= 0 && index < scope.count
let label = aria_label.unwrap_or("Slide \{index + 1} of \{scope.count}")
let item_attrs = ui_attrs(attrs)
.data_set("slot", "carousel-item")
.data_set("orientation", carousel_orientation_value(scope.orientation))
.data_set("index", index.to_string())
.data_set("state", if active { "active" } else { "inactive" })
.data_set("active", ui_bool(active))
.role("group")
.aria_roledescription("slide")
.aria_label(label)
.aria_posinset((index + 1).to_string())
.aria_setsize(scope.count.to_string())
if active {
ignore(item_attrs.aria_current("true"))
}
@html.div(
style=ui_styles(
[
UiBoxSizing,
CarouselItemStyle,
carousel_item_orientation_style(scope.orientation),
],
style,
),
id?,
class?,
title?,
hidden?,
attrs=item_attrs,
children,
)
}
///|
/// Convenience composition for an array of already-built slide bodies.
pub fn carousel_items(
scope~ : CarouselScope,
items~ : Array[@html.Html],
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
track_attrs? : @html.Attrs,
style? : Array[String] = [],
track_style? : Array[String] = [],
item_style? : Array[String] = [],
) -> @html.Html {
let slides : Array[@html.Html] = []
for index, item in items {
slides.push(carousel_item(scope~, index~, style=item_style, item))
}
carousel_content(
scope~,
id?,
class?,
title?,
attrs?,
track_attrs?,
style~,
track_style~,
slides,
)
}
///|
fn carousel_control(
scope : CarouselScope,
previous : Bool,
disabled : Bool,
aria_label : String,
id : String?,
class : String?,
title : String?,
attrs : @html.Attrs?,
style : Array[String],
) -> @html.Html {
let can_move = if previous {
carousel_can_previous(scope)
} else {
carousel_can_next(scope)
}
let disabled = disabled || !can_move
let direction = if previous { "previous" } else { "next" }
let target = if previous {
carousel_previous_index(scope)
} else {
carousel_next_index(scope)
}
let control_attrs = ui_attrs(attrs)
.data_set("slot", "carousel-\{direction}")
.data_set("direction", direction)
.data_set("state", if disabled { "disabled" } else { "enabled" })
.data_set("variant", button_variant_value(Outline))
.data_set("size", button_size_value(IconSm))
.aria_label(aria_label)
.aria_disabled(ui_bool(disabled))
if disabled {
ignore(control_attrs.data_set("disabled", ""))
} else {
ignore(control_attrs.on_click(_ => (scope.emit)(target)))
}
@html.button(
style=ui_styles(
[
UiBoxSizing,
UiFontSans,
UiTextRendering,
UiTransition,
ButtonBaseStyle,
ButtonOutlineStyle,
ButtonSizeIconSmStyle,
CarouselControlStyle,
carousel_control_position_style(scope.orientation, previous),
ui_disabled_style(disabled),
],
style,
),
id?,
class?,
title?,
type_="button",
disabled~,
attrs=control_attrs,
@html.span(
style=[UiBoxSizing, CarouselChevronBoxStyle],
attrs=@html.Attrs::build()
.aria_hidden("true")
.data_set("icon", if previous { "previous" } else { "next" }),
@html.span(
style=[
UiBoxSizing,
CarouselChevronStyle,
carousel_control_chevron_style(scope.orientation, previous),
],
@html.nothing,
),
),
)
}
///|
/// Render the previous control bound to a carousel scope.
pub fn carousel_previous(
scope~ : CarouselScope,
disabled? : Bool = false,
aria_label? : String = "Previous slide",
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
) -> @html.Html {
carousel_control(
scope, true, disabled, aria_label, id, class, title, attrs, style,
)
}
///|
/// Render the next control bound to a carousel scope.
pub fn carousel_next(
scope~ : CarouselScope,
disabled? : Bool = false,
aria_label? : String = "Next slide",
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
) -> @html.Html {
carousel_control(
scope, false, disabled, aria_label, id, class, title, attrs, style,
)
}
///|
/// Render one indexed Carousel state for the native incremental fallback.
///
/// This synchronous primitive uses the 100% slide fallback encoded in the
/// track. Use stateful `carousel` for automatic measured snapping when public
/// `track_style`/item styles introduce gaps or non-default flex bases; callers
/// that own DOM measurement may also set `--rui-carousel-offset` themselves.
#cfg(not(target="js"))
fn[C : @html.IsChildren] render_carousel_index(
index~ : Int,
count~ : Int,
loop_? : Bool = false,
orientation? : CarouselOrientation = CarouselHorizontal,
aria_label? : String,
on_index_change? : @cmd.Emit[Int],
id? : String,
class? : String,
title? : String,
hidden? : Bool,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : (CarouselScope) -> C,
) -> @html.Html {
let count = carousel_count(count)
let index = carousel_index(index, count)
let max_index = carousel_index(count - 1, count)
let emit = on_index_change.unwrap_or(Emit(_ => @cmd.none))
render_carousel(
{ index, count, max_index, loop_, orientation, emit },
aria_label,
id,
class,
title,
hidden,
attrs,
style,
children,
)
}
///|
/// Stateful carousel root. The active slide is component-local; the scope
/// binds content, items, previous/next buttons, and orientation-aware arrow
/// keys without Embla or another runtime dependency.
#cfg(target="js")
pub fn[C : @html.IsChildren] carousel(
count~ : Int,
default_index? : Int = 0,
loop_? : Bool = false,
orientation? : CarouselOrientation = CarouselHorizontal,
aria_label? : String,
on_index_change? : @cmd.Emit[Int],
id? : String,
class? : String,
title? : String,
hidden? : Bool,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : (CarouselScope) -> C,
) -> @rabbita.Val[@html.Html] {
let count = carousel_count(count)
let initial = carousel_index(default_index, count)
let root_id = id.unwrap_or(carousel_next_id())
let initial_max = carousel_index(count - 1, count)
let (model, emit) = @rabbita.create_state_with_init(
init=fn(emit) {
(
{ index: initial, max_index: initial_max },
carousel_sync_layout_cmd(root_id, emit),
)
},
update=fn(emit, message, current) {
match message {
CarouselSetIndex(requested) => {
let next = carousel_index(requested, count).clamp(
min=0,
max=current.max_index,
)
let notify = if on_index_change is Some(callback) {
callback(next)
} else {
@cmd.none
}
(
{ ..current, index: next },
@cmd.batch([notify, carousel_sync_layout_cmd(root_id, emit)]),
)
}
CarouselMeasured(measured) => {
let max_index = carousel_index(measured, count)
let index = current.index.clamp(min=0, max=max_index)
if max_index == current.max_index && index == current.index {
(current, @cmd.none)
} else {
let notify = if index != current.index &&
on_index_change is Some(callback) {
callback(index)
} else {
@cmd.none
}
(
{ index, max_index },
@cmd.batch([notify, carousel_sync_layout_cmd(root_id, emit)]),
)
}
}
}
},
)
model.view(model => {
let set_index = emit.map(index => CarouselSetIndex(index))
render_carousel(
{
index: model.index,
count,
max_index: model.max_index,
loop_,
orientation,
emit: set_index,
},
aria_label,
Some(root_id),
class,
title,
hidden,
attrs,
style,
children,
)
})
}
///|
/// Native/SSR fallback for `carousel`; renders the requested initial scope as
/// a constant incremental value.
#cfg(not(target="js"))
pub fn[C : @html.IsChildren] carousel(
count~ : Int,
default_index? : Int = 0,
loop_? : Bool = false,
orientation? : CarouselOrientation = CarouselHorizontal,
aria_label? : String,
on_index_change? : @cmd.Emit[Int],
id? : String,
class? : String,
title? : String,
hidden? : Bool,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : (CarouselScope) -> C,
) -> @rabbita.Val[@html.Html] {
ignore(on_index_change)
let count = carousel_count(count)
let index = carousel_index(default_index, count)
let emit = @cmd.Emit(_ => @cmd.none)
@rabbita.Val::constant(
render_carousel_index(
index~,
count~,
loop_~,
orientation~,
aria_label?,
on_index_change=emit,
id?,
class?,
title?,
hidden?,
attrs?,
style~,
children,
),
)
}