feat: Use a web component

A more ergonomic number spinner on mobile.
A cleaner number spinner interface.
This commit is contained in:
Jeremy Wall 2023-11-25 22:06:43 -05:00
parent a3aa579fa5
commit 1432dcea13
6 changed files with 244 additions and 123 deletions

View File

@ -54,6 +54,7 @@ version = "= 0.2.84"
version = "0.3"
features = [
"Event",
"InputEvent",
"CustomEvent",
"EventTarget",
"History",

View File

@ -11,11 +11,199 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use maud::html;
use sycamore::prelude::*;
use tracing::debug;
use web_sys::{Event, HtmlInputElement};
use tracing::{debug, error};
use wasm_bindgen::JsCast;
use wasm_web_component::{web_component, WebComponentBinding};
use web_sys::{CustomEvent, Event, HtmlElement, InputEvent, ShadowRoot, window};
use crate::js_lib;
use crate::js_lib::LogFailures;
#[web_component(
observed_attrs = "['val', 'min', 'max', 'step']",
observed_events = "['change', 'click', 'input']"
)]
pub struct NumberSpinner {
root: Option<ShadowRoot>,
min: i32,
max: i32,
step: i32,
value: i32,
}
impl NumberSpinner {
fn get_input_el(&self) -> HtmlElement {
self.root
.as_ref()
.unwrap()
.get_element_by_id("nval")
.unwrap()
.dyn_into()
.unwrap()
}
}
impl WebComponentBinding for NumberSpinner {
fn init_mut(&mut self, element: &web_sys::HtmlElement) {
(self.min, self.max, self.step, self.value) = (0, 99, 1, 0);
debug!("Initializing element instance");
let root = html! {
span {
link rel="stylesheet" href="/ui/static/app.css" { };
style {
r#"
span { display: block; }
span.button {
font-size: 2em; font-weight: bold;
}
.number-input {
border-width: var(--border-width);
border-style: inset;
padding: 3pt;
border-radius: 10px;
width: 3em;
}
"#
};
span class="button" id="inc" { "+" }; " "
// TODO(jwall): plaintext-only would be nice but I can't actually do that yet.
span id="nval" class="number-input" contenteditable="true" { "0" } " "
span class="button" id="dec" { "-" };
};
};
self.attach_shadow(element, &root.into_string());
self.root = element.shadow_root();
}
fn connected_mut(&mut self, element: &HtmlElement) {
debug!("COUNTS: connecting to DOM");
let val = element.get_attribute("val").unwrap_or_else(|| "0".into());
let min = element.get_attribute("min").unwrap_or_else(|| "0".into());
let max = element.get_attribute("max").unwrap_or_else(|| "99".into());
let step = element.get_attribute("step").unwrap_or_else(|| "1".into());
debug!(?val, ?min, ?max, ?step, "connecting to DOM");
let nval_el = self.get_input_el();
if let Ok(parsed) = val.parse::<i32>() {
self.value = parsed;
nval_el.set_inner_text(&val);
}
if let Ok(parsed) = min.parse::<i32>() {
self.min = parsed;
}
if let Ok(parsed) = max.parse::<i32>() {
self.max = parsed;
}
if let Ok(parsed) = step.parse::<i32>() {
self.step = parsed;
}
}
fn handle_event_mut(&mut self, element: &web_sys::HtmlElement, event: &Event) {
let target: HtmlElement = event.target().unwrap().dyn_into().unwrap();
let id = target.get_attribute("id");
let event_type = event.type_();
let nval_el = self.get_input_el();
debug!(?id, ?event_type, "saw event");
match (id.as_ref().map(|s| s.as_str()), event_type.as_str()) {
(Some("inc"), "click") => {
if self.value < self.max {
self.value += 1;
nval_el.set_inner_text(&format!("{}", self.value));
}
}
(Some("dec"), "click") => {
if self.value > self.min {
self.value -= 1;
nval_el.set_inner_text(&format!("{}", self.value));
}
}
(Some("nval"), "input") => {
let input_event = event.dyn_ref::<InputEvent>().unwrap();
if let Some(data) = input_event.data() {
// We only allow numeric input data here.
debug!(data, input_type=?input_event.input_type() , "got input");
if data.chars().filter(|c| !c.is_numeric()).count() > 0 {
nval_el.set_inner_text(&format!("{}", self.value));
}
} else {
nval_el.set_inner_text(&format!("{}{}", nval_el.inner_text(), self.value));
}
}
_ => {
debug!("Ignoring event");
return;
}
};
element
.set_attribute("val", &format!("{}", self.value))
.swallow_and_log();
element
.dispatch_event(&CustomEvent::new("updated").unwrap())
.unwrap();
debug!("Dispatched updated event");
}
fn attribute_changed_mut(
&mut self,
_element: &web_sys::HtmlElement,
name: wasm_bindgen::JsValue,
old_value: wasm_bindgen::JsValue,
new_value: wasm_bindgen::JsValue,
) {
let nval_el = self.get_input_el();
let name = name.as_string().unwrap();
debug!(?name, ?old_value, ?new_value, "COUNTS: handling attribute change");
match name.as_str() {
"val" => {
debug!("COUNTS: got an updated value");
if let Some(val) = new_value.as_string() {
debug!(val, "COUNTS: got an updated value");
if let Ok(val) = val.parse::<i32>() {
self.value = val;
nval_el.set_inner_text(format!("{}", self.value).as_str());
} else {
error!(?new_value, "COUNTS: Not a valid f64 value");
}
}
}
"min" => {
if let Some(val) = new_value.as_string() {
debug!(val, "COUNTS: got an updated value");
if let Ok(val) = val.parse::<i32>() {
self.min = val;
} else {
error!(?new_value, "COUNTS: Not a valid f64 value");
}
}
}
"max" => {
if let Some(val) = new_value.as_string() {
debug!(val, "COUNTS: got an updated value");
if let Ok(val) = val.parse::<i32>() {
self.max = val;
} else {
error!(?new_value, "COUNTS: Not a valid f64 value");
}
}
}
"step" => {
if let Some(val) = new_value.as_string() {
debug!(val, "COUNTS: got an updated value");
if let Ok(val) = val.parse::<i32>() {
self.step = val;
} else {
error!(?new_value, "COUNTS: Not a valid f64 value");
}
}
}
_ => {
debug!("Ignoring Attribute Change");
return;
}
}
}
}
#[derive(Props)]
pub struct NumberProps<'ctx, F>
@ -39,36 +227,26 @@ where
min,
counter,
} = props;
NumberSpinner::define_once();
// TODO(jwall): I'm pretty sure this triggers: https://github.com/sycamore-rs/sycamore/issues/602
// Which means I probably have to wait till v0.9.0 drops or switch to leptos.
let id = name.clone();
let inc_target_id = id.clone();
let dec_target_id = id.clone();
let min_field = format!("{}", min);
view! {cx,
div() {
input(type="number", id=id, name=name, class="item-count-sel", min=min_field, max="99", step="1", bind:valueAsNumber=counter, on:input=move |evt| {
on_change.as_ref().map(|f| f(evt));
})
span(class="item-count-inc-dec", on:click=move |_| {
let i = *counter.get_untracked();
let target = js_lib::get_element_by_id::<HtmlInputElement>(&inc_target_id).unwrap().expect(&format!("No such element with id {}", inc_target_id));
counter.set(i+1.0);
debug!(counter=%(counter.get_untracked()), "set counter to new value");
// We force an input event to get triggered for our target.
target.dispatch_event(&web_sys::Event::new("input").expect("Failed to create new event")).expect("Failed to dispatch event to target");
}) { "" }
" "
span(class="item-count-inc-dec", on:click=move |_| {
let i = *counter.get_untracked();
let target = js_lib::get_element_by_id::<HtmlInputElement>(&dec_target_id).unwrap().expect(&format!("No such element with id {}", dec_target_id));
if i > min {
counter.set(i-1.0);
debug!(counter=%(counter.get_untracked()), "set counter to new value");
// We force an input event to get triggered for our target.
target.dispatch_event(&web_sys::Event::new("input").expect("Failed to create new event")).expect("Failed to dispatch event to target");
}
}) { "" }
create_effect(cx, move || {
let new_count = *counter.get();
debug!(new_count, "COUNTS: Updating spinner with new value");
if let Some(el) = window().unwrap().document().unwrap().get_element_by_id(id.as_str()) {
debug!("COUNTS: found element");
el.set_attribute("val", new_count.to_string().as_str()).unwrap();
}
});
let id = name.clone();
view! {cx,
number-spinner(id=id, val=*counter.get(), min=min, on:updated=move |evt: Event| {
let target: HtmlElement = evt.target().unwrap().dyn_into().unwrap();
let val: f64 = target.get_attribute("val").unwrap().parse().unwrap();
counter.set(val);
on_change.as_ref().map(|f| f(evt));
debug!(counter=%(counter.get_untracked()), "set counter to new value");
})
}
}

View File

@ -52,20 +52,20 @@ pub fn CategoryGroup<'ctx, G: Html>(
});
view! {cx,
h2 { (category) }
table(class="recipe_selector no-print") {
div(class="recipe_selector no-print") {
(View::new_fragment(
rows.get().iter().cloned().map(|r| {
view ! {cx,
tr { Keyed(
Keyed(
iterable=r,
view=move |cx, sig| {
let title = create_memo(cx, move || sig.get().1.title.clone());
view! {cx,
td { RecipeSelection(i=sig.get().0.to_owned(), title=title, sh=sh) }
div(class="cell") { RecipeSelection(i=sig.get().0.to_owned(), title=title, sh=sh) }
}
},
key=|sig| sig.get().0.to_owned(),
)}
)
}
}).collect()
))

View File

@ -65,12 +65,10 @@ pub fn RecipeSelection<'ctx, G: Html>(
let name = format!("recipe_id:{}", id);
let for_id = name.clone();
view! {cx,
div() {
label(for=for_id) { a(href=href) { (*title) } }
NumberField(name=name, counter=count, min=0.0, on_change=Some(move |_| {
debug!(idx=%id, count=%(*count.get_untracked()), "setting recipe count");
sh.dispatch(cx, Message::UpdateRecipeCount(id.as_ref().clone(), *count.get_untracked() as usize));
}))
}
label(for=for_id) { a(href=href) { (*title) } }
NumberField(name=name, counter=count, min=0.0, on_change=Some(move |_| {
debug!(idx=%id, count=%(*count.get_untracked()), "setting recipe count");
sh.dispatch(cx, Message::UpdateRecipeCount(id.as_ref().clone(), *count.get_untracked() as usize));
}))
}
}

View File

@ -1,79 +0,0 @@
// Copyright 2023 Jeremy Wall (Jeremy@marzhilsltudios.com)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.\
use sycamore::{easing, motion, prelude::*};
use tracing::debug;
use wasm_bindgen::UnwrapThrowExt;
const SECTION_ID: &'static str = "toast-container";
#[component]
pub fn Container<'a, G: Html>(cx: Scope<'a>) -> View<G> {
view! {cx,
section(id=SECTION_ID) { }
}
}
pub fn create_output_element(msg: &str, class: &str) -> web_sys::Element {
let document = web_sys::window()
.expect("No window present")
.document()
.expect("No document in window");
let output = document.create_element("output").unwrap_throw();
let message_node = document.create_text_node(msg);
output.set_attribute("class", class).unwrap_throw();
output.set_attribute("role", "status").unwrap_throw();
output.append_child(&message_node).unwrap_throw();
output
}
fn show_toast<'a>(cx: Scope<'a>, msg: &str, class: &str, timeout: Option<chrono::Duration>) {
let timeout = timeout.unwrap_or_else(|| chrono::Duration::seconds(3));
// Insert a toast output element into the container.
let tweened = motion::create_tweened_signal(
cx,
0.0 as f32,
timeout
.to_std()
.expect("Failed to convert timeout duration."),
easing::quad_in,
);
tweened.set(1.0);
create_effect_scoped(cx, move |_cx| {
if !tweened.is_tweening() {
debug!("Detected message timeout.");
let container = crate::js_lib::get_element_by_id::<web_sys::HtmlElement>(SECTION_ID)
.expect("Failed to get toast-container")
.expect("No toast-container");
if let Some(node_to_remove) = container.first_element_child() {
// Always remove the first child if there is one.
container.remove_child(&node_to_remove).unwrap_throw();
}
}
});
let output_element = create_output_element(msg, class);
crate::js_lib::get_element_by_id::<web_sys::HtmlElement>(SECTION_ID)
.expect("Failed to get toast-container")
.expect("No toast-container")
// Always append after the last child.
.append_child(&output_element)
.unwrap_throw();
}
pub fn message<'a>(cx: Scope<'a>, msg: &str, timeout: Option<chrono::Duration>) {
show_toast(cx, msg, "toast", timeout);
}
pub fn error_message<'a>(cx: Scope<'a>, msg: &str, timeout: Option<chrono::Duration>) {
show_toast(cx, msg, "toast error", timeout);
}

View File

@ -23,6 +23,8 @@
--notification-font-size: calc(var(--font-size) / 2);
--error-message-color: rgba(255, 98, 0, 0.797);
--error-message-bg: grey;
--border-width: 2px;
--cell-margin: 1em;
}
@media print {
@ -127,3 +129,24 @@ nav>h1 {
opacity: 0
}
}
.recipe_selector {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
align-items: stretch;
align-content: stretch;
}
.recipe_selector .cell {
margin: 1em;
width: calc(100% / 5);
}
.cell {
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: stretch;
align-content: stretch;
}