yewprint/
panel_stack.rs

1use crate::{Button, Icon};
2use gloo::timers::callback::Timeout;
3use implicit_clone::ImplicitClone;
4use std::cell::RefCell;
5use std::fmt;
6use std::rc::Rc;
7use yew::prelude::*;
8
9pub struct PanelBuilder<F: Fn(Option<Html>, I) -> O, I, O> {
10    title: Option<Html>,
11    input: I,
12    finish: F,
13}
14
15impl<F, I, O> PanelBuilder<F, I, O>
16where
17    F: Fn(Option<Html>, I) -> O,
18{
19    fn new(input: I, finish: F) -> PanelBuilder<F, I, O> {
20        Self {
21            title: None,
22            input,
23            finish,
24        }
25    }
26
27    pub fn with_title(self, title: Html) -> PanelBuilder<F, I, O> {
28        Self {
29            title: Some(title),
30            ..self
31        }
32    }
33
34    pub fn finish(self) -> O {
35        (self.finish)(self.title, self.input)
36    }
37}
38
39#[derive(Clone)]
40pub struct PanelStackState {
41    opened_panels: Rc<RefCell<Vec<(Option<Html>, Html)>>>,
42    version: usize,
43    action: Option<StateAction>,
44}
45
46impl ImplicitClone for PanelStackState {}
47
48impl PanelStackState {
49    pub fn new(content: Html) -> PanelBuilder<fn(Option<Html>, Html) -> Self, Html, Self> {
50        PanelBuilder::new(content, |title, content| {
51            let instance = PanelStackState {
52                opened_panels: Default::default(),
53                version: Default::default(),
54                action: Default::default(),
55            };
56
57            instance.opened_panels.borrow_mut().push((title, content));
58
59            instance
60        })
61    }
62
63    pub fn close_panel(&mut self) -> bool {
64        let opened_panels = self.opened_panels.borrow();
65        if opened_panels.len() > 1 {
66            self.version = self.version.wrapping_add(1);
67            self.action.replace(StateAction::Pop);
68            true
69        } else {
70            false
71        }
72    }
73
74    pub fn open_panel(
75        &mut self,
76        content: Html,
77    ) -> PanelBuilder<
78        fn(Option<Html>, (Html, Rc<RefCell<Vec<(Option<Html>, Html)>>>)) -> bool,
79        (Html, Rc<RefCell<Vec<(Option<Html>, Html)>>>),
80        bool,
81    > {
82        let opened_panels = self.opened_panels.clone();
83        self.version = self.version.wrapping_add(1);
84        self.action.replace(StateAction::Push);
85        PanelBuilder::new(
86            (content, opened_panels),
87            |title, (content, opened_panels)| {
88                opened_panels.borrow_mut().push((title, content));
89                true
90            },
91        )
92    }
93}
94
95impl PartialEq for PanelStackState {
96    fn eq(&self, other: &Self) -> bool {
97        Rc::ptr_eq(&self.opened_panels, &other.opened_panels) && self.version == other.version
98    }
99}
100
101impl fmt::Debug for PanelStackState {
102    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
103        write!(f, "PanelStackState({})", self.version)
104    }
105}
106
107#[derive(Debug, Copy, Clone, PartialEq)]
108enum StateAction {
109    Push,
110    Pop,
111}
112
113impl From<StateAction> for Classes {
114    fn from(action: StateAction) -> Self {
115        Classes::from(match action {
116            StateAction::Push => "bp3-panel-stack2-push",
117            StateAction::Pop => "bp3-panel-stack2-pop",
118        })
119    }
120}
121
122pub struct PanelStack {
123    timeout_task: Option<Timeout>,
124
125    // We keep track of the latest action to perform from the PanelStackState
126    // because we need a mutable access to the action.
127    action_to_perform: Option<StateAction>,
128}
129
130#[derive(Debug, Clone, PartialEq, Properties)]
131pub struct PanelStackProps {
132    pub state: PanelStackState,
133    #[prop_or_default]
134    pub onclose: Option<Callback<()>>,
135    #[prop_or_default]
136    pub class: Classes,
137}
138
139pub enum PanelStackMessage {
140    PopPanel,
141}
142
143impl Component for PanelStack {
144    type Message = PanelStackMessage;
145    type Properties = PanelStackProps;
146
147    fn create(_ctx: &Context<Self>) -> Self {
148        Self {
149            timeout_task: None,
150            action_to_perform: None,
151        }
152    }
153
154    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
155        match msg {
156            PanelStackMessage::PopPanel => {
157                ctx.props().state.opened_panels.borrow_mut().pop();
158                true
159            }
160        }
161    }
162
163    fn view(&self, ctx: &Context<Self>) -> Html {
164        let opened_panels = ctx.props().state.opened_panels.borrow();
165        let last = match self.action_to_perform {
166            Some(StateAction::Pop) => opened_panels.len() - 2,
167            _ => opened_panels.len() - 1,
168        };
169
170        html! {
171            <div
172                class={classes!(
173                    "bp3-panel-stack2",
174                    self.action_to_perform,
175                    ctx.props().class.clone(),
176                )}
177            >
178            {
179                opened_panels
180                    .iter()
181                    .enumerate()
182                    .rev()
183                    .map(|(i, (title, content))| html! {
184                        <Panel
185                            title={title.clone()}
186                            animation={
187                                match self.action_to_perform {
188                                    _ if i == last => Animation::EnterStart,
189                                    Some(StateAction::Push) if i == last - 1 =>
190                                        Animation::ExitStart,
191                                    Some(StateAction::Pop) if i == last + 1 =>
192                                        Animation::ExitStart,
193                                    _ => Animation::Exited,
194                                }
195                            }
196                            onclose={(i > 0).then(|| ctx.props().onclose.clone()).flatten()}
197                            key={i}
198                        >
199                            // TODO the state of content doesn't seem to be kept when re-opening
200                            //      a panel using the same components
201                            {content.clone()}
202                        </Panel>
203                    })
204                    .collect::<Html>()
205            }
206            </div>
207        }
208    }
209
210    fn changed(&mut self, ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
211        self.action_to_perform = ctx.props().state.action;
212        true
213    }
214
215    fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
216        if self.action_to_perform.take() == Some(StateAction::Pop) {
217            let link = ctx.link().clone();
218            self.timeout_task.replace(Timeout::new(400, move || {
219                link.send_message(PanelStackMessage::PopPanel)
220            }));
221        }
222    }
223}
224
225struct Panel {
226    animation: Animation,
227    timeout_task: Option<Timeout>,
228}
229
230#[derive(Debug, Clone, PartialEq, Properties)]
231struct PanelProps {
232    title: Option<Html>,
233    animation: Animation,
234    onclose: Option<Callback<()>>,
235    children: Children,
236}
237
238#[derive(Debug, Copy, Clone, PartialEq)]
239enum PanelMessage {
240    UpdateAnimation(Animation),
241}
242
243impl Component for Panel {
244    type Message = PanelMessage;
245    type Properties = PanelProps;
246
247    fn create(ctx: &Context<Self>) -> Self {
248        Self {
249            animation: ctx.props().animation,
250            timeout_task: None,
251        }
252    }
253
254    fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
255        match msg {
256            PanelMessage::UpdateAnimation(animation) => {
257                self.animation = animation;
258                true
259            }
260        }
261    }
262
263    fn view(&self, ctx: &Context<Self>) -> Html {
264        let style = if self.animation == Animation::Exited {
265            "display:none"
266        } else {
267            "display:flex"
268        };
269        let classes = classes!(
270            "bp3-panel-stack-view",
271            match self.animation {
272                Animation::EnterStart => Some("bp3-panel-stack2-enter"),
273                Animation::Entering => Some("bp3-panel-stack2-enter bp3-panel-stack2-enter-active"),
274                Animation::Entered => None,
275                Animation::ExitStart => Some("bp3-panel-stack2-exit"),
276                Animation::Exiting => Some("bp3-panel-stack2-exit bp3-panel-stack2-exit-active"),
277                Animation::Exited => None,
278            }
279        );
280        let back_button = ctx.props().onclose.clone().map(|onclose| {
281            html! {
282                <Button
283                    class={classes!("bp3-panel-stack-header-back")}
284                    style={"padding-right:0"}
285                    icon={Icon::ChevronLeft}
286                    minimal={true}
287                    small={true}
288                    onclick={onclose.reform(|_| ())}
289                >
290                    // TODO: I get a lot of "VComp is not mounted" if I try to use the title
291                    //       of the previous panel
292                </Button>
293            }
294        });
295
296        html! {
297            <div class={classes} style={style}>
298                <div class="bp3-panel-stack-header">
299                    <span>{back_button}</span>
300                    {ctx.props().title.clone()}
301                    <span/>
302                </div>
303                {for ctx.props().children.iter()}
304            </div>
305        }
306    }
307
308    fn changed(&mut self, ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
309        self.animation = ctx.props().animation;
310        true
311    }
312
313    fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
314        match self.animation {
315            Animation::EnterStart => {
316                let link = ctx.link().clone();
317                self.timeout_task.replace(Timeout::new(0, move || {
318                    link.send_message(PanelMessage::UpdateAnimation(Animation::Entering));
319                }));
320            }
321            Animation::Entering => {
322                let link = ctx.link().clone();
323                self.timeout_task.replace(Timeout::new(400, move || {
324                    link.send_message(PanelMessage::UpdateAnimation(Animation::Entered));
325                }));
326            }
327            Animation::Entered => {}
328            Animation::ExitStart => {
329                let link = ctx.link().clone();
330                self.timeout_task.replace(Timeout::new(0, move || {
331                    link.send_message(PanelMessage::UpdateAnimation(Animation::Exiting));
332                }));
333            }
334            Animation::Exiting => {
335                let link = ctx.link().clone();
336                self.timeout_task.replace(Timeout::new(400, move || {
337                    link.send_message(PanelMessage::UpdateAnimation(Animation::Exited));
338                }));
339            }
340            Animation::Exited => {}
341        }
342    }
343}
344
345#[derive(Debug, Copy, Clone, PartialEq)]
346enum Animation {
347    EnterStart,
348    Entering,
349    Entered,
350    ExitStart,
351    Exiting,
352    Exited,
353}