From 1432dcea13327ce8ae1b37ab05e899820d321d42 Mon Sep 17 00:00:00 2001 From: Jeremy Wall Date: Sat, 25 Nov 2023 22:06:43 -0500 Subject: [PATCH] feat: Use a web component A more ergonomic number spinner on mobile. A cleaner number spinner interface. --- web/Cargo.toml | 1 + web/src/components/number_field.rs | 242 +++++++++++++++++++++---- web/src/components/recipe_plan.rs | 8 +- web/src/components/recipe_selection.rs | 12 +- web/src/components/toast.rs | 79 -------- web/static/app.css | 25 ++- 6 files changed, 244 insertions(+), 123 deletions(-) delete mode 100644 web/src/components/toast.rs diff --git a/web/Cargo.toml b/web/Cargo.toml index a98b5e6..45bfb4a 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -54,6 +54,7 @@ version = "= 0.2.84" version = "0.3" features = [ "Event", + "InputEvent", "CustomEvent", "EventTarget", "History", diff --git a/web/src/components/number_field.rs b/web/src/components/number_field.rs index 889d4ec..e0fd487 100644 --- a/web/src/components/number_field.rs +++ b/web/src/components/number_field.rs @@ -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, + 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::() { + self.value = parsed; + nval_el.set_inner_text(&val); + } + if let Ok(parsed) = min.parse::() { + self.min = parsed; + } + if let Ok(parsed) = max.parse::() { + self.max = parsed; + } + if let Ok(parsed) = step.parse::() { + 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::().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::() { + 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::() { + 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::() { + 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::() { + 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::(&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::(&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"); + }) } } diff --git a/web/src/components/recipe_plan.rs b/web/src/components/recipe_plan.rs index 607119b..31c6968 100644 --- a/web/src/components/recipe_plan.rs +++ b/web/src/components/recipe_plan.rs @@ -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() )) diff --git a/web/src/components/recipe_selection.rs b/web/src/components/recipe_selection.rs index ec741f5..4fb66c7 100644 --- a/web/src/components/recipe_selection.rs +++ b/web/src/components/recipe_selection.rs @@ -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)); + })) } } diff --git a/web/src/components/toast.rs b/web/src/components/toast.rs deleted file mode 100644 index 1a86319..0000000 --- a/web/src/components/toast.rs +++ /dev/null @@ -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 { - 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) { - 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::(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::(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) { - show_toast(cx, msg, "toast", timeout); -} - -pub fn error_message<'a>(cx: Scope<'a>, msg: &str, timeout: Option) { - show_toast(cx, msg, "toast error", timeout); -} diff --git a/web/static/app.css b/web/static/app.css index 2e35c8c..70a6006 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -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 { @@ -126,4 +128,25 @@ nav>h1 { to { opacity: 0 } -} \ No newline at end of file +} + +.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; +}