///|
/// Edge from which a [`drawer_content`] surface is presented.
pub(all) enum DrawerDirection {
DrawerTop
DrawerRight
DrawerBottom
DrawerLeft
} derive(Debug, Eq)
///|
/// Explicit compound-component scope created by [`drawer`].
struct DrawerScope(DialogScope)
///|
const DrawerPositionerStyle : String = "position:fixed;inset:0;z-index:51;display:flex;overflow:hidden;pointer-events:none"
///|
const DrawerSurfaceStyle : String = "position:relative;display:flex;min-width:0;min-height:0;flex-direction:column;overflow:auto;background:var(--rui-popover,var(--rui-background,oklch(1 0 0)));color:var(--rui-popover-foreground,var(--rui-foreground,oklch(0.145 0 0)));box-shadow:0 16px 48px rgb(0 0 0 / 0.2);opacity:var(--rui-drawer-opacity,1);transform:var(--rui-drawer-transform,translate3d(0,0,0));will-change:opacity,transform;pointer-events:auto;transition:opacity 200ms cubic-bezier(0.22,1,0.36,1),transform 200ms cubic-bezier(0.22,1,0.36,1)"
///|
const DrawerHeaderStyle : String = "display:flex;min-width:0;flex-direction:column;gap:0.375rem;padding:1rem 1.5rem;text-align:center"
///|
const DrawerFooterStyle : String = "display:flex;flex-direction:column-reverse;gap:0.5rem;padding:1rem 1.5rem"
///|
const DrawerCloseButtonStyle : String = "position:absolute;top:1rem;inset-inline-end:1rem;z-index:1;opacity:var(--rui-close-opacity,var(--rui-close-base-opacity,0.7))"
///|
const DrawerHandleInteractionStyle : String = "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_positioner_direction_style(direction : DrawerDirection) -> String {
match direction {
DrawerTop => "align-items:flex-start"
DrawerRight => "justify-content:flex-end"
DrawerBottom => "align-items:flex-end"
DrawerLeft => "justify-content:flex-start"
}
}
///|
fn drawer_surface_direction_style(direction : DrawerDirection) -> String {
match direction {
DrawerTop =>
"width:100%;max-height:96vh;border-bottom:1px solid var(--rui-border,oklch(0.922 0 0));border-radius:0 0 var(--rui-radius,0.625rem) var(--rui-radius,0.625rem)"
DrawerRight =>
"width:min(90vw,24rem);height:100%;border-left:1px solid var(--rui-border,oklch(0.922 0 0));border-radius:var(--rui-radius,0.625rem) 0 0 var(--rui-radius,0.625rem)"
DrawerBottom =>
"width:100%;max-height:96vh;border-top:1px solid var(--rui-border,oklch(0.922 0 0));border-radius:var(--rui-radius,0.625rem) var(--rui-radius,0.625rem) 0 0"
DrawerLeft =>
"width:min(90vw,24rem);height:100%;border-right:1px solid var(--rui-border,oklch(0.922 0 0));border-radius:0 var(--rui-radius,0.625rem) var(--rui-radius,0.625rem) 0"
}
}
///|
fn drawer_closed_transform(direction : DrawerDirection) -> String {
match direction {
DrawerTop => "translate3d(0,calc(-100% - 2px),0)"
DrawerRight => "translate3d(calc(100% + 2px),0,0)"
DrawerBottom => "translate3d(0,calc(100% + 2px),0)"
DrawerLeft => "translate3d(calc(-100% - 2px),0,0)"
}
}
///|
fn drawer_motion_style(direction : DrawerDirection, open : Bool) -> String {
let closed_transform = drawer_closed_transform(direction)
if open {
"--rui-drawer-closed-transform:\{closed_transform};--rui-drawer-opacity:1;--rui-drawer-transform:translate3d(0,0,0);animation:rui-drawer-enter 200ms cubic-bezier(0.22,1,0.36,1)"
} else {
"--rui-drawer-closed-transform:\{closed_transform};--rui-drawer-opacity:0;--rui-drawer-transform:\{closed_transform};animation:rui-drawer-exit 200ms cubic-bezier(0.22,1,0.36,1);pointer-events:none"
}
}
///|
#cfg(target="js")
priv struct DrawerPointerState {
surface : @dom.Element
pointer_id : Int
horizontal : Bool
sign : Int
start_x : Int
start_y : Int
start_time : Double
size : Double
mut active : Bool
overlay : @dom.Element?
original_transform : String
original_transition : String
original_overlay_opacity : String
}
///|
#cfg(target="js")
let drawer_pointer_states : Array[DrawerPointerState] = []
///|
#cfg(target="js")
fn drawer_pointer_state(surface : @dom.Element) -> DrawerPointerState? {
for state in drawer_pointer_states {
if state.surface.is_same_node(surface.as_node()) {
return Some(state)
}
}
None
}
///|
#cfg(target="js")
fn drawer_remove_pointer_state(surface : @dom.Element) -> Unit {
for index, state in drawer_pointer_states {
if state.surface.is_same_node(surface.as_node()) {
ignore(drawer_pointer_states.remove(index))
return
}
}
}
///|
#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)
}
}
///|
#cfg(target="js")
fn drawer_release_pointer_state(
state : DrawerPointerState,
restore_drag_values : Bool,
) -> Unit {
if state.surface.to_html_element() is Some(surface) {
let style = surface.get_style()
if restore_drag_values {
drawer_restore_style_property(
style,
"--rui-drawer-transform",
state.original_transform,
)
}
drawer_restore_style_property(
style,
"transition",
state.original_transition,
)
}
if restore_drag_values &&
state.overlay is Some(overlay) &&
overlay.to_html_element() is Some(overlay_html) {
drawer_restore_style_property(
overlay_html.get_style(),
"--rui-dialog-overlay-opacity",
state.original_overlay_opacity,
)
}
state.surface.remove_attribute("data-dragging")
if state.surface.has_pointer_capture(state.pointer_id) {
state.surface.release_pointer_capture(state.pointer_id)
}
drawer_remove_pointer_state(state.surface)
}
///|
#cfg(target="js")
fn drawer_pointer_start(event : @dom.MouseEvent, direction : String) -> 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) {
return false
}
guard pointer.current_target().to_option() is Some(current_target) else {
return false
}
guard current_target.to_element() is Some(surface) else { return false }
guard surface.to_html_element() is Some(surface_html) else { return false }
guard pointer.target().to_element() is Some(target) else { return false }
guard target.closest("[data-slot=\"drawer-handle\"]") is Some(handle) else {
return false
}
guard surface.contains(handle.as_node()) else { return false }
if drawer_pointer_state(surface) is Some(previous) {
drawer_release_pointer_state(previous, true)
}
for animation in surface.get_animations() {
animation.finish()
}
let horizontal = direction == "left" || direction == "right"
let overlay = if surface.closest("dialog") is Some(dialog) {
dialog.query_selector("[data-slot=\"drawer-overlay\"]")
} else {
None
}
let style = surface_html.get_style()
let original_overlay_opacity = if overlay is Some(overlay) &&
overlay.to_html_element() is Some(overlay_html) {
overlay_html.get_style().get_property_value("--rui-dialog-overlay-opacity")
} else {
""
}
let state : DrawerPointerState = {
surface,
pointer_id: pointer.get_pointer_id(),
horizontal,
sign: if direction == "left" || direction == "top" {
-1
} else {
1
},
start_x: pointer.get_client_x(),
start_y: pointer.get_client_y(),
start_time: pointer.get_time_stamp(),
size: if horizontal {
surface.get_client_width()
} else {
surface.get_client_height()
},
active: false,
overlay,
original_transform: style.get_property_value("--rui-drawer-transform"),
original_transition: style.get_property_value("transition"),
original_overlay_opacity,
}
drawer_pointer_states.push(state)
surface.set_pointer_capture(state.pointer_id)
true
}
///|
#cfg(target="js")
fn drawer_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(surface) else { return false }
guard drawer_pointer_state(surface) is Some(state) else { return false }
guard state.pointer_id == pointer.get_pointer_id() else { return false }
guard surface.to_html_element() is Some(surface_html) else { return false }
let delta = 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
}
let progress = delta * state.sign
if !state.active {
if delta.abs() < 6 || delta.abs() <= cross.abs() {
return false
}
state.active = true
surface.set_attribute("data-dragging", "true")
}
let visual = if progress < 0 {
delta.to_double() * 0.2
} else {
delta.to_double()
}
let style = surface_html.get_style()
style.set_property("transition", "none")
style.set_property(
"--rui-drawer-transform",
if state.horizontal {
"translate3d(\{visual}px,0,0)"
} else {
"translate3d(0,\{visual}px,0)"
},
)
if state.overlay is Some(overlay) &&
overlay.to_html_element() is Some(overlay_html) {
let ratio = (progress.to_double() / state.size.max(1.0)).clamp(
min=0.0,
max=1.0,
)
overlay_html
.get_style()
.set_property("--rui-dialog-overlay-opacity", "\{1.0 - ratio}")
}
true
}
///|
#cfg(target="js")
fn drawer_pointer_end(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(surface) else { return false }
guard drawer_pointer_state(surface) is Some(state) else { return false }
guard state.pointer_id == pointer.get_pointer_id() else { return false }
let delta = if state.horizontal {
pointer.get_client_x() - state.start_x
} else {
pointer.get_client_y() - state.start_y
}
let progress = delta * state.sign
let elapsed = (pointer.get_time_stamp() - state.start_time).max(1.0)
let threshold = (state.size * 0.25).max(48.0).min(160.0)
let dismiss = state.active &&
progress > 0 &&
(
progress.to_double() >= threshold ||
(progress >= 16 && progress.to_double() / elapsed >= 0.5)
)
drawer_release_pointer_state(state, !dismiss)
dismiss
}
///|
#cfg(target="js")
fn drawer_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(surface) else { return }
guard drawer_pointer_state(surface) is Some(state) else { return }
guard state.pointer_id == pointer.get_pointer_id() else { return }
drawer_release_pointer_state(state, true)
}
///|
#cfg(target="js")
fn drawer_bind_pointer_attrs(
attrs : @html.Attrs,
direction : DrawerDirection,
close_command : @cmd.Cmd,
) -> Unit {
ignore(
attrs
.on_pointerdown(event => {
ignore(drawer_pointer_start(event, drawer_direction_value(direction)))
@cmd.none
})
.on_pointermove(event => {
if drawer_pointer_move(event) {
event.prevent_default()
}
@cmd.none
})
.on_pointerup(event => {
if drawer_pointer_end(event) {
event.prevent_default()
close_command
} else {
@cmd.none
}
})
.on_pointercancel(event => {
drawer_pointer_cancel(event)
@cmd.none
}),
)
}
///|
#cfg(not(target="js"))
fn drawer_bind_pointer_attrs(
attrs : @html.Attrs,
direction : DrawerDirection,
close_command : @cmd.Cmd,
) -> Unit {
ignore((attrs, direction, close_command))
}
///|
fn drawer_handle_style(direction : DrawerDirection) -> String {
match direction {
DrawerTop =>
"width:3rem;height:0.25rem;margin:0.5rem auto 0;border-radius:999px;background:var(--rui-muted,oklch(0.97 0 0))"
DrawerBottom =>
"width:3rem;height:0.25rem;margin:0.5rem auto 0;border-radius:999px;background:var(--rui-muted,oklch(0.97 0 0))"
DrawerRight =>
"width:0.25rem;height:3rem;margin:auto 0 auto 0.5rem;border-radius:999px;background:var(--rui-muted,oklch(0.97 0 0))"
DrawerLeft =>
"width:0.25rem;height:3rem;margin:auto 0.5rem auto auto;border-radius:999px;background:var(--rui-muted,oklch(0.97 0 0))"
}
}
///|
fn drawer_close_icon(scope : DialogScope, label : String) -> @html.Html {
dialog_control_button(
slot="drawer-close",
command=scope.close_command,
variant=Ghost,
size=IconSm,
disabled=false,
id=None,
class=None,
title=None,
aria_label=Some(label),
on_click=None,
attrs=Some(
@html.Attrs::build()
.data_set("rui-close-icon", "")
.data_set("state", dialog_state_value(scope.open)),
),
style=[DrawerCloseButtonStyle],
ui_x_icon(),
)
}
///|
/// Create a stateful native-dialog drawer. Modal drawers use native focus
/// containment, restoration, Escape handling, and top-layer stacking;
/// `modal=false` intentionally leaves background controls available.
pub fn drawer(
id~ : String,
default_open? : Bool = false,
modal? : Bool = true,
close_on_escape? : Bool = true,
on_open_change? : @cmd.Emit[Bool],
on_close? : @cmd.Emit[String],
children : (DrawerScope) -> @html.Html,
) -> @rabbita.Val[@html.Html] {
dialog(
id~,
default_open~,
modal~,
close_on_escape~,
on_open_change?,
on_close?,
scope => children(DrawerScope(scope)),
)
}
///|
pub fn[C : @html.IsChildren] drawer_trigger(
scope : DrawerScope,
variant? : ButtonVariant = Outline,
size? : ButtonSize = Default,
disabled? : Bool = false,
id? : String,
class? : String,
title? : String,
aria_label? : String,
on_click? : @cmd.Cmd,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
let dialog_scope = scope.0
let trigger_attrs = dialog_state_attrs(
attrs,
"drawer-trigger",
dialog_scope.open,
)
.aria_haspopup("dialog")
.aria_controls(dialog_scope.id)
.aria_expanded(ui_bool(dialog_scope.open))
dialog_control_button(
slot="drawer-trigger",
command=dialog_scope.open_command,
variant~,
size~,
disabled~,
id~,
class~,
title~,
aria_label~,
on_click~,
attrs=Some(trigger_attrs),
style~,
children,
)
}
///|
/// Pass native drawer content through without adding a DOM node.
pub fn drawer_portal(children : Array[@html.Html]) -> @html.Html {
@html.fragment(children)
}
///|
/// Render the drawer backdrop associated with a compound drawer scope.
pub fn drawer_overlay(
scope : DrawerScope,
close_on_overlay? : Bool = true,
id? : String,
class? : String,
title? : String,
hidden? : Bool,
attrs? : @html.Attrs,
style? : Array[String] = [],
) -> @html.Html {
dialog_overlay_part(
scope.0,
slot="drawer-overlay",
close_on_overlay~,
id~,
class~,
title~,
hidden~,
attrs~,
style~,
)
}
///|
/// Render an edge drawer. `show_handle` adds the familiar shadcn/Vaul visual
/// affordance; it is decorative, while all dismissal remains available through
/// native Escape, backdrop, and explicit close controls.
pub fn[C : @html.IsChildren] drawer_content(
scope : DrawerScope,
direction? : DrawerDirection = DrawerBottom,
close_on_overlay? : Bool = true,
show_handle? : Bool = true,
show_close? : Bool = false,
close_label? : String = "Close",
described? : Bool = true,
aria_label? : String,
id? : String,
class? : String,
title? : String,
hidden? : Bool,
attrs? : @html.Attrs,
style? : Array[String] = [],
overlay_attrs? : @html.Attrs,
overlay_style? : Array[String] = [],
native_attrs? : @html.Attrs,
native_style? : Array[String] = [],
children : C,
) -> @html.Html {
let dialog_scope = scope.0
let native_element_attrs = dialog_state_attrs(
native_attrs,
"drawer-native",
dialog_scope.open,
)
.data_set("modal", ui_bool(dialog_scope.modal))
.data_set("close-on-outside", ui_bool(close_on_overlay))
.data_set("close-on-escape", ui_bool(dialog_scope.close_on_escape))
if dialog_scope.modal {
ignore(native_element_attrs.aria_modal("true"))
}
if aria_label is Some(label) {
ignore(native_element_attrs.aria_label(label))
} else {
ignore(native_element_attrs.aria_labelledby(dialog_scope.title_id()))
}
if described {
ignore(native_element_attrs.aria_describedby(dialog_scope.description_id()))
}
let content_attrs = dialog_state_attrs(
attrs,
"drawer-content",
dialog_scope.open,
).data_set("direction", drawer_direction_value(direction))
drawer_bind_pointer_attrs(
content_attrs,
direction,
dialog_scope.close_command,
)
let positioner_attrs = dialog_state_attrs(
None,
"drawer-positioner",
dialog_scope.open,
).data_set("direction", drawer_direction_value(direction))
let content = @html.div(
style=ui_styles(
[
UiBoxSizing,
DrawerSurfaceStyle,
drawer_surface_direction_style(direction),
drawer_motion_style(direction, dialog_scope.open),
],
style,
),
id?,
class?,
title?,
hidden?,
attrs=content_attrs,
[
if show_handle {
drawer_handle(direction~)
} else {
@html.nothing
},
@html.div(style=[UiBoxSizing, "display:contents"], children),
if show_close {
drawer_close_icon(dialog_scope, close_label)
} else {
@html.nothing
},
],
)
let open = dialog_scope.native_open
let on_close = dialog_scope.on_close
let on_cancel = dialog_scope.on_cancel
let closedby = if !dialog_scope.modal {
"none"
} else if close_on_overlay {
"any"
} else if dialog_scope.close_on_escape {
"closerequest"
} else {
"none"
}
drawer_portal([
@html.dialog(
style=ui_styles([UiBoxSizing, DialogNativeStyle], native_style),
id=dialog_scope.id,
open?,
closedby~,
on_close?,
on_cancel?,
attrs=native_element_attrs,
[
if dialog_scope.modal {
drawer_overlay(
scope,
close_on_overlay~,
attrs=ui_attrs(overlay_attrs),
style=overlay_style,
)
} else {
@html.nothing
},
@html.div(
style=[
UiBoxSizing,
DrawerPositionerStyle,
drawer_positioner_direction_style(direction),
],
attrs=positioner_attrs,
content,
),
],
),
])
}
///|
/// Render the drawer's visual grab handle. It is intentionally presentation
/// only; keyboard and assistive-technology users use the regular controls.
pub fn drawer_handle(
direction? : DrawerDirection = DrawerBottom,
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
) -> @html.Html {
@html.div(
style=ui_styles(
[
UiBoxSizing,
drawer_handle_style(direction),
DrawerHandleInteractionStyle,
],
style,
),
id?,
class?,
title?,
attrs=ui_attrs(attrs)
.data_set("slot", "drawer-handle")
.data_set("direction", drawer_direction_value(direction))
.aria_hidden("true"),
@html.nothing,
)
}
///|
pub fn[C : @html.IsChildren] drawer_header(
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
@html.div(
style=ui_styles([UiBoxSizing, DrawerHeaderStyle], style),
id?,
class?,
title?,
attrs=ui_attrs(attrs).data_set("slot", "drawer-header"),
children,
)
}
///|
pub fn[C : @html.IsChildren] drawer_footer(
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
@html.div(
style=ui_styles([UiBoxSizing, DrawerFooterStyle], style),
id?,
class?,
title?,
attrs=ui_attrs(attrs).data_set("slot", "drawer-footer"),
children,
)
}
///|
pub fn[C : @html.IsChildren] drawer_title(
scope : DrawerScope,
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
let element_id = id.unwrap_or(scope.0.title_id())
@html.h2(
style=ui_styles([UiBoxSizing, DialogTitleStyle], style),
id=element_id,
class?,
title?,
attrs=ui_attrs(attrs).data_set("slot", "drawer-title"),
children,
)
}
///|
pub fn[C : @html.IsChildren] drawer_description(
scope : DrawerScope,
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
let element_id = id.unwrap_or(scope.0.description_id())
@html.p(
style=ui_styles([UiBoxSizing, DialogDescriptionStyle], style),
id=element_id,
class?,
title?,
attrs=ui_attrs(attrs).data_set("slot", "drawer-description"),
children,
)
}
///|
pub fn[C : @html.IsChildren] drawer_close(
scope : DrawerScope,
variant? : ButtonVariant = Outline,
size? : ButtonSize = Default,
disabled? : Bool = false,
id? : String,
class? : String,
title? : String,
aria_label? : String,
on_click? : @cmd.Cmd,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
dialog_control_button(
slot="drawer-close",
command=scope.0.close_command,
variant~,
size~,
disabled~,
id~,
class~,
title~,
aria_label~,
on_click~,
attrs~,
style~,
children,
)
}