Skip to main content

perspective_viewer/components/containers/
select.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::borrow::{Borrow, Cow};
14use std::fmt::Display;
15use std::rc::Rc;
16
17use wasm_bindgen::JsCast;
18use yew::prelude::*;
19
20#[derive(Properties)]
21pub struct SelectProps<T>
22where
23    T: Clone + Display + PartialEq + 'static,
24{
25    pub values: Rc<Vec<SelectItem<T>>>,
26    pub selected: T,
27    pub on_select: Callback<T>,
28
29    #[prop_or_default]
30    pub is_autosize: bool,
31
32    #[prop_or_default]
33    pub label: Option<Cow<'static, str>>,
34
35    #[prop_or_default]
36    pub id: Option<&'static str>,
37
38    #[prop_or_default]
39    pub class: Option<String>,
40
41    #[prop_or_default]
42    pub wrapper_class: Option<String>,
43}
44
45impl<T> PartialEq for SelectProps<T>
46where
47    T: Clone + Display + PartialEq + 'static,
48{
49    fn eq(&self, rhs: &Self) -> bool {
50        self.selected == rhs.selected && self.values == rhs.values
51    }
52}
53
54pub enum SelectMsg {
55    SelectedChanged(i32),
56    KeyboardInput(bool, i32, String),
57}
58
59/// A `<select>` HTML elements, lifted to support parameterization over a set of
60/// values of a type `T`.
61pub struct Select<T>
62where
63    T: Clone + Display + PartialEq + 'static,
64{
65    select_ref: NodeRef,
66    selected: T,
67}
68
69impl<T> Component for Select<T>
70where
71    T: Clone + Display + PartialEq + 'static,
72{
73    type Message = SelectMsg;
74    type Properties = SelectProps<T>;
75
76    fn create(_ctx: &Context<Self>) -> Self {
77        Self {
78            select_ref: NodeRef::default(),
79            selected: _ctx.props().selected.clone(),
80        }
81    }
82
83    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
84        match msg {
85            SelectMsg::SelectedChanged(x) => {
86                self.selected = find_nth(x, &ctx.props().values).unwrap().clone();
87                ctx.props().on_select.emit(self.selected.clone());
88                true
89            },
90
91            // All the up and down arrow keys to instantly select, which is
92            // useful for keyboard warrios. This is not ada without the shift
93            // key. I'm not sure if it is _with_ the shift key either, but its
94            // cool.
95            SelectMsg::KeyboardInput(is_shift, idx, code) => {
96                if is_shift {
97                    if code.as_str() == "ArrowUp" {
98                        if let Some(x) = find_nth(idx - 1, &ctx.props().values) {
99                            self.selected = x.clone();
100                            ctx.props().on_select.emit(self.selected.clone());
101                            return true;
102                        }
103                    } else if code.as_str() == "ArrowDown" {
104                        if let Some(x) = find_nth(idx + 1, &ctx.props().values) {
105                            self.selected = x.clone();
106                            ctx.props().on_select.emit(self.selected.clone());
107                            return true;
108                        }
109                    }
110                }
111
112                false
113            },
114        }
115    }
116
117    // The `<select>` has its own state not refelcted by `SelectProps`.
118    fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
119        self.selected = ctx.props().selected.clone();
120        true
121    }
122
123    // Annoyingly, `<select>` cannot be updated from its HTML alone.
124    fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
125        if let Some(elem) = self.select_ref.cast::<web_sys::HtmlSelectElement>() {
126            elem.set_value(&format!("{}", self.selected))
127        }
128    }
129
130    fn view(&self, ctx: &Context<Self>) -> Html {
131        let callback = ctx.link().callback(|event: InputEvent| {
132            let value = event
133                .target()
134                .unwrap()
135                .unchecked_into::<web_sys::HtmlSelectElement>()
136                .selected_index();
137            SelectMsg::SelectedChanged(value)
138        });
139
140        let key_callback = ctx.link().callback(|event: KeyboardEvent| {
141            event.prevent_default();
142            let value = event
143                .target()
144                .unwrap()
145                .unchecked_into::<web_sys::HtmlSelectElement>()
146                .selected_index();
147
148            let is_shift = event.shift_key();
149            SelectMsg::KeyboardInput(is_shift, value, event.code())
150        });
151
152        let class = if let Some(class) = &ctx.props().class {
153            format!("noselect {class}")
154        } else {
155            "noselect".to_owned()
156        };
157
158        let is_group_selected = !ctx
159            .props()
160            .values
161            .iter()
162            .any(|x| matches!(x, SelectItem::Option(y) if *y == ctx.props().selected));
163
164        let select = html! {
165            <select
166                id={ctx.props().id}
167                {class}
168                ref={&self.select_ref}
169                oninput={callback}
170                onkeydown={key_callback}
171            >
172                { for ctx.props().values.iter().map(|value| match value {
173                        SelectItem::Option(value) => {
174                            let selected = *value == ctx.props().selected;
175                            html! {
176                                <option
177                                    key={ format!("{}", value) }
178                                    selected={ selected }
179                                    value={ format!("{value}") }>
180                                    { format!("{}", value) }
181                                </option>
182                            }
183                        },
184                        SelectItem::OptGroup(name, group) => html! {
185                            <optgroup
186                                key={ name.to_string() }
187                                label={ name.to_string() }>
188                                {
189                                    for group.iter().map(|value| {
190                                        let selected =
191                                            *value == ctx.props().selected;
192
193                                        let label = format!("{value}");
194                                        let category: &str = name.borrow();
195                                        let label = label
196                                            .strip_prefix(category)
197                                            .unwrap_or(&label)
198                                            .trim()
199                                            .to_owned();
200
201                                        html! {
202                                            <option
203                                                key={ format!("{}", value) }
204                                                selected={ selected }
205                                                value={ format!("{value}") }>
206                                                { label }
207                                            </option>
208                                        }
209                                    })
210                                }
211                            </optgroup>
212                        }
213                    }) }
214            </select>
215        };
216
217        let wrapper_class = match &ctx.props().wrapper_class {
218            Some(x) => classes!("dropdown-width-container", x),
219            None => classes!("dropdown-width-container"),
220        };
221
222        let value = if ctx.props().is_autosize {
223            self.selected.to_string()
224        } else {
225            "".to_owned()
226        };
227
228        html! {
229            if is_group_selected && ctx.props().label.is_some() {
230                <label>
231                    { ctx.props().label.as_ref().map(|x| x.to_string()).unwrap_or_default() }
232                </label>
233                <div class={wrapper_class} data-value={value.clone()}>{ select }</div>
234            } else {
235                <div class={wrapper_class} data-value={value}>{ select }</div>
236            }
237        }
238    }
239}
240
241#[derive(Clone, Eq, PartialEq)]
242pub enum SelectItem<T> {
243    Option(T),
244    OptGroup(Cow<'static, str>, Vec<T>),
245}
246
247impl<T: Display> SelectItem<T> {
248    pub fn name<'a>(&self) -> Cow<'a, str> {
249        match self {
250            Self::Option(x) => format!("{x}").into(),
251            Self::OptGroup(x, _) => x.clone(),
252        }
253    }
254}
255
256fn find_nth<T>(mut count: i32, items: &[SelectItem<T>]) -> Option<&T> {
257    for ref item in items.iter() {
258        match item {
259            SelectItem::Option(_) if count > 0 => {
260                count -= 1;
261            },
262            SelectItem::OptGroup(_, items) if count >= items.len() as i32 => {
263                count -= items.len() as i32;
264            },
265            SelectItem::OptGroup(_, items) => return items.get(count as usize),
266            SelectItem::Option(x) => return Some(x),
267        }
268    }
269
270    None
271}