Skip to main content

perspective_viewer/components/
filter_dropdown.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 std::cell::RefCell;
14use std::collections::HashSet;
15use std::rc::Rc;
16
17use web_sys::*;
18use yew::html::ImplicitClone;
19use yew::prelude::*;
20
21use super::style::StyleSurface;
22use crate::session::Session;
23use crate::ui::PortalModal;
24use crate::utils::*;
25use crate::*;
26
27/// The suggestion list while it is on screen, the only state a key may act on.
28struct OpenState {
29    values: Vec<String>,
30    selected: usize,
31    on_select: Callback<String>,
32    target: HtmlElement,
33}
34
35#[derive(Default)]
36enum FilterDropDownState {
37    #[default]
38    Hidden,
39    Open(OpenState),
40}
41
42#[derive(Clone)]
43pub struct FilterDropDownElement {
44    state: Rc<RefCell<FilterDropDownState>>,
45    session: Session,
46    column: Rc<RefCell<Option<(usize, String)>>>,
47    all_values: Rc<RefCell<Option<Vec<String>>>>,
48    notify: Rc<PubSub<()>>,
49}
50
51impl PartialEq for FilterDropDownElement {
52    fn eq(&self, other: &Self) -> bool {
53        Rc::ptr_eq(&self.state, &other.state)
54    }
55}
56
57impl ImplicitClone for FilterDropDownElement {}
58
59impl FilterDropDownElement {
60    pub fn new(session: Session) -> Self {
61        Self {
62            state: Default::default(),
63            session,
64            column: Default::default(),
65            all_values: Default::default(),
66            notify: Rc::default(),
67        }
68    }
69
70    pub fn reautocomplete(&self) {
71        self.notify.emit(());
72    }
73
74    pub fn autocomplete(
75        &self,
76        column: (usize, String),
77        input: String,
78        exclude: HashSet<String>,
79        target: HtmlElement,
80        callback: Callback<String>,
81    ) {
82        let current_column = self.column.borrow().clone();
83        match current_column {
84            Some(filter_col) if filter_col == column => {
85                let values = filter_values(&input, &self.all_values, &exclude);
86                self.show(values, &input, target, callback);
87            },
88            _ => {
89                let this = self.clone();
90                ApiFuture::spawn(async move {
91                    let fetched =
92                        crate::queries::get_column_values(&this.session, column.1.clone()).await?;
93                    *this.all_values.borrow_mut() = Some(fetched);
94                    *this.column.borrow_mut() = Some(column);
95                    let values = filter_values(&input, &this.all_values, &exclude);
96                    this.show(values, &input, target, callback);
97                    Ok(())
98                });
99            },
100        }
101    }
102
103    /// Open the list, or dismiss it when `input` is its one exact match.
104    fn show(
105        &self,
106        values: Vec<String>,
107        input: &str,
108        target: HtmlElement,
109        on_select: Callback<String>,
110    ) {
111        if values.len() == 1 && values[0] == input {
112            self.dismiss();
113        } else {
114            *self.state.borrow_mut() = FilterDropDownState::Open(OpenState {
115                values,
116                selected: 0,
117                on_select,
118                target,
119            });
120
121            self.notify.emit(());
122        }
123    }
124
125    /// Accept a visible list's highlighted suggestion; a hidden list has none.
126    pub fn item_select(&self) {
127        let selected = match &*self.state.borrow() {
128            FilterDropDownState::Open(open) => open
129                .values
130                .get(open.selected)
131                .map(|value| (value.clone(), open.on_select.clone())),
132            FilterDropDownState::Hidden => None,
133        };
134
135        if let Some((value, on_select)) = selected {
136            on_select.emit(value);
137        }
138    }
139
140    pub fn item_down(&self) {
141        self.step(1);
142    }
143
144    pub fn item_up(&self) {
145        self.step(-1);
146    }
147
148    fn step(&self, delta: isize) {
149        let moved = match &mut *self.state.borrow_mut() {
150            FilterDropDownState::Open(open) if !open.values.is_empty() => {
151                let len = open.values.len() as isize;
152                open.selected = (open.selected as isize + delta).rem_euclid(len) as usize;
153                true
154            },
155            _ => false,
156        };
157
158        if moved {
159            self.notify.emit(());
160        }
161    }
162
163    /// Close the list, keeping the column's values for the next keystroke.
164    pub fn dismiss(&self) {
165        *self.state.borrow_mut() = FilterDropDownState::Hidden;
166        self.notify.emit(());
167    }
168
169    /// Close the list and forget the column so the next focus refetches it.
170    pub fn reset(&self) {
171        self.column.borrow_mut().take();
172        self.all_values.borrow_mut().take();
173        self.dismiss();
174    }
175}
176
177#[derive(Properties, PartialEq)]
178pub struct FilterDropDownPortalProps {
179    pub element: FilterDropDownElement,
180    pub theme: String,
181}
182
183pub struct FilterDropDownPortal {
184    _sub: Subscription,
185}
186
187impl Component for FilterDropDownPortal {
188    type Message = ();
189    type Properties = FilterDropDownPortalProps;
190
191    fn create(ctx: &Context<Self>) -> Self {
192        let link = ctx.link().clone();
193        let sub = ctx
194            .props()
195            .element
196            .notify
197            .add_listener(move |()| link.send_message(()));
198        Self { _sub: sub }
199    }
200
201    fn update(&mut self, _ctx: &Context<Self>, _msg: ()) -> bool {
202        true
203    }
204
205    fn view(&self, ctx: &Context<Self>) -> Html {
206        let element = &ctx.props().element;
207        let state = element.state.borrow();
208        let FilterDropDownState::Open(open) = &*state else {
209            return html! {};
210        };
211
212        let on_close = {
213            let element = element.clone();
214            Callback::from(move |()| element.dismiss())
215        };
216
217        html! {
218            <PortalModal
219                tag_name="perspective-dropdown"
220                sheet={StyleSurface::FilterDropdown.sheet()}
221                target={Some(open.target.clone())}
222                own_focus=false
223                {on_close}
224                theme={ctx.props().theme.clone()}
225            >
226                <FilterDropDownView
227                    values={open.values.clone()}
228                    selected={open.selected}
229                    on_select={open.on_select.clone()}
230                />
231            </PortalModal>
232        }
233    }
234}
235
236#[derive(Properties, PartialEq)]
237struct FilterDropDownViewProps {
238    values: Vec<String>,
239    selected: usize,
240    on_select: Callback<String>,
241}
242
243#[function_component]
244fn FilterDropDownView(props: &FilterDropDownViewProps) -> Html {
245    let body = html! {
246        if !props.values.is_empty() {
247            { for props.values
248                    .iter()
249                    .enumerate()
250                    .map(|(idx, value)| {
251                        let click = props.on_select.reform({
252                            let value = value.clone();
253                            move |_: MouseEvent| value.clone()
254                        });
255
256                        html! {
257                            if idx == props.selected {
258                                <span onmousedown={click} class="selected">{ value }</span>
259                            } else {
260                                <span onmousedown={click}>{ value }</span>
261                            }
262                        }
263                    }) }
264        } else {
265            <span class="no-results">{ "No Completions" }</span>
266        }
267    };
268
269    html! { <>{ body }</> }
270}
271
272fn filter_values(
273    input: &str,
274    values: &Rc<RefCell<Option<Vec<String>>>>,
275    exclude: &HashSet<String>,
276) -> Vec<String> {
277    let input = input.to_lowercase();
278    if let Some(values) = &*values.borrow() {
279        values
280            .iter()
281            .filter(|x| x.to_lowercase().contains(&input) && !exclude.contains(x.as_str()))
282            .take(10)
283            .cloned()
284            .collect::<Vec<String>>()
285    } else {
286        vec![]
287    }
288}