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