///|
/// Edge from which a [`drawer`] enters the viewport.
pub(all) enum DrawerDirection {
DrawerTop
DrawerRight
DrawerBottom
DrawerLeft
} derive(Debug, Eq)
///|
pub extend DrawerDirection with Debug::{to_repr}
///|
pub extend DrawerDirection with Eq::{equal, not_equal}
///|
const DrawerStyle : String = "position:fixed;min-width:0;min-height:0;max-width:none;margin:var(--rui-drawer-inset,0.5rem);border:1px solid light-dark(var(--rui-popover,oklch(1 0 0)),var(--rui-border,oklch(1 0 0 / 0.1)));border-radius:var(--rui-radius,0.625rem);padding:0;overflow:visible;box-shadow:0 20px 25px -5px rgb(0 0 0 / 0.1),0 8px 10px -6px rgb(0 0 0 / 0.1);background:var(--rui-popover,var(--rui-background,oklch(1 0 0)));color:var(--rui-popover-foreground,var(--rui-foreground,oklch(0.145 0 0)));font-size:0.875rem;line-height:1.25rem;outline:none"
///|
const DrawerContentStyle : String = "display:flex;min-width:0;min-height:0;flex:1;flex-direction:column;padding:1rem;overflow:auto;overscroll-behavior:contain;border-radius:inherit"
///|
const DrawerHeaderStyle : String = "display:flex;min-width:0;flex-shrink:0;flex-direction:column;gap:var(--rui-drawer-header-gap,0.125rem);padding:0 0 1rem;text-align:var(--rui-drawer-header-align,left)"
///|
const DrawerFooterStyle : String = "display:flex;flex-shrink:0;flex-direction:column;gap:0.5rem;margin-block-start:auto;padding:1rem 0 0"
///|
const DrawerTitleStyle : String = "margin:0;font-size:1rem;line-height:1.5rem;font-weight:500;color:var(--rui-foreground,oklch(0.145 0 0))"
///|
const DrawerDescriptionStyle : String = "margin:0;font-size:0.875rem;line-height:1.25rem;text-wrap:balance;color:var(--rui-muted-foreground,oklch(0.556 0 0))"
///|
const DrawerSwipeAreaStyle : String = "position:absolute;cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none"
///|
fn drawer_direction_value(direction : DrawerDirection) -> String {
match direction {
DrawerTop => "top"
DrawerRight => "right"
DrawerBottom => "bottom"
DrawerLeft => "left"
}
}
///|
fn drawer_direction_style(direction : DrawerDirection) -> String {
match direction {
DrawerTop =>
"inset:0 0 auto;width:calc(100% - 2 * var(--rui-drawer-inset,0.5rem));max-height:calc(100dvh - 6rem);flex-direction:column;--rui-drawer-closed-transform:translateY(calc(-100% - var(--rui-drawer-inset,0.5rem) - 2px))"
DrawerRight =>
"inset:0 0 0 auto;width:var(--rui-drawer-side-width,75vw);height:calc(100dvh - 2 * var(--rui-drawer-inset,0.5rem));max-height:calc(100dvh - 2 * var(--rui-drawer-inset,0.5rem));flex-direction:row;--rui-drawer-closed-transform:translateX(calc(100% + var(--rui-drawer-inset,0.5rem) + 2px))"
DrawerBottom =>
"inset:auto 0 0;width:calc(100% - 2 * var(--rui-drawer-inset,0.5rem));max-height:calc(100dvh - 6rem);flex-direction:column;--rui-drawer-closed-transform:translateY(calc(100% + var(--rui-drawer-inset,0.5rem) + 2px))"
DrawerLeft =>
"inset:0 auto 0 0;width:var(--rui-drawer-side-width,75vw);height:calc(100dvh - 2 * var(--rui-drawer-inset,0.5rem));max-height:calc(100dvh - 2 * var(--rui-drawer-inset,0.5rem));flex-direction:row;--rui-drawer-closed-transform:translateX(calc(-100% - var(--rui-drawer-inset,0.5rem) - 2px))"
}
}
///|
#cfg(target="js")
fn drawer_restore_style_property(
style : @dom.CSSStyleDeclaration,
name : String,
value : String,
) -> Unit {
if value == "" {
ignore(style.remove_property(name))
} else {
style.set_property(name, value)
}
}
///|
// Gesture data lives only for this pointer capture; native HTML owns open/close.
#cfg(target="js")
fn drawer_pointer_start(
event : @dom.MouseEvent,
direction : DrawerDirection,
) -> Unit {
guard event.to_pointer_event() is Some(pointer) else { return }
if pointer.get_default_prevented() ||
!pointer.get_is_primary() ||
(pointer.get_pointer_type() == "mouse" && pointer.get_button() != 0) {
return
}
guard pointer.current_target().to_option() is Some(current_target) else {
return
}
guard current_target.to_element() is Some(surface) else { return }
guard surface.to_html_element() is Some(surface_html) else { return }
guard surface.to_html_dialog_element() is Some(dialog) else { return }
guard pointer.target().to_element() is Some(target) else { return }
guard target.closest("[data-slot=\"drawer-swipe-area\"]") is Some(swipe_area) else {
return
}
guard swipe_area.closest("dialog") is Some(owner) &&
owner.is_same_node(surface.as_node()) else {
return
}
let pointer_id = pointer.get_pointer_id()
if swipe_area.has_pointer_capture(pointer_id) {
return
}
for animation in surface.get_animations() {
animation.finish()
}
let horizontal = direction == DrawerLeft || direction == DrawerRight
let sign = if direction == DrawerLeft || direction == DrawerTop {
-1.0
} else {
1.0
}
let start_x = pointer.get_client_x()
let start_y = pointer.get_client_y()
let start_time = pointer.get_time_stamp()
let size = if horizontal {
surface.get_client_width()
} else {
surface.get_client_height()
}
let active = Ref(false)
let style = surface_html.get_style()
let original_transform = style.get_property_value(
"--rui-drawer-drag-transform",
)
let original_opacity = style.get_property_value(
"--rui-drawer-backdrop-opacity",
)
let listeners : Array[(String, @dom.Listener)] = []
fn cleanup(_event : @dom.Event) -> Unit {
for (name, listener) in listeners {
surface.remove_event_listener(name, listener)
}
surface.remove_attribute("data-dragging")
drawer_restore_style_property(
style, "--rui-drawer-drag-transform", original_transform,
)
drawer_restore_style_property(
style, "--rui-drawer-backdrop-opacity", original_opacity,
)
if swipe_area.has_pointer_capture(pointer_id) {
swipe_area.release_pointer_capture(pointer_id)
}
}
fn on_move(event : @dom.Event) -> Unit {
guard event.to_pointer_event() is Some(pointer) else { return }
guard pointer.get_pointer_id() == pointer_id else { return }
let delta = if horizontal {
pointer.get_client_x() - start_x
} else {
pointer.get_client_y() - start_y
}
let cross = if horizontal {
pointer.get_client_y() - start_y
} else {
pointer.get_client_x() - start_x
}
if !active.val {
if delta.abs() < 6 || delta.abs() <= cross.abs() {
return
}
active.val = true
surface.set_attribute("data-dragging", "")
}
event.prevent_default()
let progress = delta * sign
let visual = if progress < 0 { delta * 0.2 } else { delta }
style.set_property(
"--rui-drawer-drag-transform",
if horizontal {
"translate3d(\{visual}px,0,0)"
} else {
"translate3d(0,\{visual}px,0)"
},
)
let ratio = (progress / size.max(1.0)).clamp(min=0.0, max=1.0)
style.set_property("--rui-drawer-backdrop-opacity", "\{1.0 - ratio}")
}
fn finish(event : @dom.Event) -> Unit {
guard event.to_pointer_event() is Some(pointer) else { return }
guard pointer.get_pointer_id() == pointer_id else { return }
let delta = if horizontal {
pointer.get_client_x() - start_x
} else {
pointer.get_client_y() - start_y
}
let progress = delta * sign
let elapsed = (pointer.get_time_stamp() - start_time).max(1.0)
let threshold = (size * 0.25).max(48.0).min(160.0)
let clicked_outside = !active.val &&
(pointer.get_client_x() - start_x).abs() < 6 &&
(pointer.get_client_y() - start_y).abs() < 6 &&
surface.get_attribute("closedby") is Some("any")
if clicked_outside ||
(
active.val &&
progress > 0 &&
(
progress >= threshold ||
(progress >= 16.0 && progress / elapsed >= 0.5)
)
) {
event.prevent_default()
// Restore transitions before the native close starts the exit animation.
surface.remove_attribute("data-dragging")
dialog.request_close()
}
cleanup(event)
}
listeners.push(("pointermove", on_move))
listeners.push(("pointerup", finish))
listeners.push(("pointercancel", cleanup))
listeners.push(("lostpointercapture", cleanup))
listeners.push(("close", cleanup))
for (name, listener) in listeners {
surface.add_event_listener(name, listener)
}
swipe_area.set_pointer_capture(pointer_id)
}
///|
#cfg(target="js")
fn drawer_bind_pointer_attrs(
attrs : @html.Attrs,
direction : DrawerDirection,
) -> Unit {
ignore(
attrs.on_pointerdown(event => {
drawer_pointer_start(event, direction)
@cmd.none
}),
)
}
///|
#cfg(not(target="js"))
fn drawer_bind_pointer_attrs(
attrs : @html.Attrs,
direction : DrawerDirection,
) -> Unit {
ignore((attrs, direction))
}
///|
/// Render a floating native drawer.
/// Set `swipe_area` to a positive pixel size to enable a transparent swipe
/// area outside its inward-facing edge (width for side drawers, height for
/// top/bottom drawers). It is disabled by default.
/// `--rui-drawer-inset` controls the viewport gutter (defaults to 0.5rem).
/// Open it with `@rabbita/dialog.show(id)` and close it with a
/// `