perspective_viewer/components/containers/sidebar.rs
1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors. ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13use perspective_client::clone;
14use wasm_bindgen::JsCast;
15use wasm_bindgen::prelude::Closure;
16use web_sys::HtmlElement;
17use yew::{
18 Callback, Children, Html, Properties, function_component, html, use_effect_with, use_mut_ref,
19 use_node_ref,
20};
21
22use crate::components::containers::sidebar_close_button::SidebarCloseButton;
23use crate::components::editable_header::{EditableHeader, EditableHeaderProps};
24use crate::js::{ResizeObserver, ResizeObserverEntry};
25
26#[derive(PartialEq, Clone, Properties)]
27pub struct SidebarProps {
28 /// The component's children.
29 pub children: Children,
30
31 /// When this callback is called, the sidebar will close
32 pub on_close: Callback<()>,
33 pub id_prefix: String,
34 pub width_override: Option<i32>,
35 pub selected_tab: Option<usize>,
36 pub header_props: EditableHeaderProps,
37
38 /// Trap-door width shared across this sidebar's tabs: the lifted
39 /// running max of the widths this component reports through
40 /// `on_auto_width`. Held by the parent (ultimately
41 /// `PerspectiveViewer`'s geometry state, like the settings panel's
42 /// Query/Plugin/Debug trap-door) so it survives tab switches AND
43 /// sidebar re-mounts, and clears on divider reset.
44 #[prop_or_default]
45 pub auto_width: f64,
46
47 /// Fires with the sidebar's rendered width after each render; the
48 /// owner keeps the running max threaded back as `auto_width`.
49 #[prop_or_default]
50 pub on_auto_width: Callback<f64>,
51
52 /// Pinned state for the header's pin toggle; the button renders only
53 /// when `on_toggle_pin` is provided.
54 #[prop_or_default]
55 pub is_pinned: bool,
56
57 #[prop_or_default]
58 pub on_toggle_pin: Option<Callback<()>>,
59}
60
61/// Sidebars are designed to live in a
62/// [`super::split_panel::SplitPanel`]
63#[function_component]
64pub fn Sidebar(p: &SidebarProps) -> Html {
65 let id = &p.id_prefix;
66 let noderef = use_node_ref();
67
68 // The trap-door reports the sidebar's rendered width to its owner via
69 // a `ResizeObserver` on the sidebar element, NOT a render effect:
70 // width changes are driven by DOM mutations anywhere in the tab
71 // subtree (e.g. the window editor staging a long column name into a
72 // slot), which need not re-render this component at all. The observer
73 // sees every one. `contentRect` (not the border box) is load-bearing:
74 // the sizer below is a content child, so ratcheting the border box
75 // would feed any sidebar padding back into unbounded growth. A manual
76 // divider drag (`width_override`) disables the ratchet until the
77 // divider resets.
78 let live_props = use_mut_ref(|| (Callback::<f64>::default(), None::<i32>));
79 *live_props.borrow_mut() = (p.on_auto_width.clone(), p.width_override);
80 use_effect_with((), {
81 clone!(noderef, live_props);
82 move |_| {
83 let closure =
84 Closure::<dyn FnMut(js_sys::Array)>::new(move |entries: js_sys::Array| {
85 let (on_auto_width, width_override) = live_props.borrow().clone();
86 if width_override.is_none() {
87 for entry in entries.iter() {
88 let entry: ResizeObserverEntry = entry.unchecked_into();
89 on_auto_width.emit(entry.content_rect().width());
90 }
91 }
92 });
93
94 let observer = ResizeObserver::new(closure.as_ref().unchecked_ref());
95 let elem = noderef.cast::<HtmlElement>();
96 if let Some(elem) = &elem {
97 observer.observe(elem);
98 }
99
100 move || {
101 if let Some(elem) = &elem {
102 observer.unobserve(elem);
103 }
104
105 drop(closure);
106 }
107 }
108 });
109
110 let auto_width = if p.width_override.is_none() {
111 p.auto_width
112 } else {
113 0.0
114 };
115
116 let width_style = format!("min-width: 200px; width: {}px", auto_width);
117 let pin_button = p.on_toggle_pin.as_ref().map(|cb| {
118 let onclick = {
119 let cb = cb.clone();
120 Callback::from(move |_: web_sys::MouseEvent| cb.emit(()))
121 };
122
123 let mut class = yew::classes!("sidebar_pin_button");
124 if p.is_pinned {
125 class.push("is-pinned");
126 }
127
128 html! {
129 <span
130 id={format!("{id}_pin_button")}
131 {class}
132 title={if p.is_pinned { "Unpin" } else { "Pin" }}
133 {onclick}
134 />
135 }
136 });
137
138 html! {
139 <>
140 <SidebarCloseButton id={format!("{id}_close_button")} on_close_sidebar={&p.on_close} />
141 <div class="sidebar_column" id={format!("{id}_sidebar")} ref={noderef}>
142 <div class="sidebar_header">
143 <EditableHeader ..p.header_props.clone() />
144 { pin_button }
145 </div>
146 <div class="sidebar_border" id={format!("{id}_border")} />
147 { p.children.iter().collect::<Html>() }
148 <div class="sidebar-auto-width" style={width_style} />
149 </div>
150 </>
151 }
152}