Skip to main content

perspective_viewer/ui/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            SelectMsg::KeyboardInput(is_shift, idx, code) => {
92                if is_shift {
93                    if code.as_str() == "ArrowUp" {
94                        if let Some(x) = find_nth(idx - 1, &ctx.props().values) {
95                            self.selected = x.clone();
96                            ctx.props().on_select.emit(self.selected.clone());
97                            return true;
98                        }
99                    } else if code.as_str() == "ArrowDown"
100                        && let Some(x) = find_nth(idx + 1, &ctx.props().values)
101                    {
102                        self.selected = x.clone();
103                        ctx.props().on_select.emit(self.selected.clone());
104                        return true;
105                    }
106                }
107
108                false
109            },
110        }
111    }
112
113    fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
114        self.selected = ctx.props().selected.clone();
115        true
116    }
117
118    fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
119        if let Some(elem) = self.select_ref.cast::<web_sys::HtmlSelectElement>() {
120            elem.set_value(&format!("{}", self.selected))
121        }
122    }
123
124    fn view(&self, ctx: &Context<Self>) -> Html {
125        let callback = ctx.link().callback(|event: InputEvent| {
126            let value = event
127                .target()
128                .unwrap()
129                .unchecked_into::<web_sys::HtmlSelectElement>()
130                .selected_index();
131            SelectMsg::SelectedChanged(value)
132        });
133
134        let key_callback = ctx.link().callback(|event: KeyboardEvent| {
135            event.prevent_default();
136            let value = event
137                .target()
138                .unwrap()
139                .unchecked_into::<web_sys::HtmlSelectElement>()
140                .selected_index();
141
142            let is_shift = event.shift_key();
143            SelectMsg::KeyboardInput(is_shift, value, event.code())
144        });
145
146        let class = if let Some(class) = &ctx.props().class {
147            format!("noselect {class}")
148        } else {
149            "noselect".to_owned()
150        };
151
152        let is_group_selected = !ctx
153            .props()
154            .values
155            .iter()
156            .any(|x| matches!(x, SelectItem::Option(y) if *y == ctx.props().selected));
157
158        let select = html! {
159            <select
160                id={ctx.props().id}
161                {class}
162                ref={&self.select_ref}
163                oninput={callback}
164                onkeydown={key_callback}
165            >
166                { for ctx.props().values.iter().map(|value| match value {
167                        SelectItem::Option(value) => {
168                            let selected = *value == ctx.props().selected;
169                            html! {
170                                <option
171                                    key={ format!("{}", value) }
172                                    selected={ selected }
173                                    value={ format!("{value}") }>
174                                    { format!("{}", value) }
175                                </option>
176                            }
177                        },
178                        SelectItem::OptGroup(name, group) => html! {
179                            <optgroup
180                                key={ name.to_string() }
181                                label={ name.to_string() }>
182                                {
183                                    for group.iter().map(|value| {
184                                        let selected =
185                                            *value == ctx.props().selected;
186
187                                        let label = format!("{value}");
188                                        let category: &str = name.borrow();
189                                        let label = label
190                                            .strip_prefix(category)
191                                            .unwrap_or(&label)
192                                            .trim()
193                                            .to_owned();
194
195                                        html! {
196                                            <option
197                                                key={ format!("{}", value) }
198                                                selected={ selected }
199                                                value={ format!("{value}") }>
200                                                { label }
201                                            </option>
202                                        }
203                                    })
204                                }
205                            </optgroup>
206                        }
207                    }) }
208            </select>
209        };
210
211        let wrapper_class = match &ctx.props().wrapper_class {
212            Some(x) => classes!("dropdown-width-container", x),
213            None => classes!("dropdown-width-container"),
214        };
215
216        let value = if ctx.props().is_autosize {
217            Some(self.selected.to_string())
218        } else {
219            None
220        };
221
222        html! {
223            if is_group_selected && ctx.props().label.is_some() {
224                <label>
225                    { ctx.props().label.as_ref().map(|x| x.to_string()).unwrap_or_default() }
226                </label>
227                <div class={wrapper_class} data-value={value}>
228                    { select }
229                    <span class="select-arrow" />
230                </div>
231            } else {
232                <div class={wrapper_class} data-value={value}>
233                    { select }
234                    <span class="select-arrow" />
235                </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}