mirror of
https://github.com/zaphar/kitchen.git
synced 2025-07-22 19:40:14 -04:00
feat: Use a web component
A more ergonomic number spinner on mobile. A cleaner number spinner interface.
This commit is contained in:
parent
a3aa579fa5
commit
1432dcea13
@ -54,6 +54,7 @@ version = "= 0.2.84"
|
|||||||
version = "0.3"
|
version = "0.3"
|
||||||
features = [
|
features = [
|
||||||
"Event",
|
"Event",
|
||||||
|
"InputEvent",
|
||||||
"CustomEvent",
|
"CustomEvent",
|
||||||
"EventTarget",
|
"EventTarget",
|
||||||
"History",
|
"History",
|
||||||
|
@ -11,11 +11,199 @@
|
|||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
use maud::html;
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
use tracing::debug;
|
use tracing::{debug, error};
|
||||||
use web_sys::{Event, HtmlInputElement};
|
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)]
|
#[derive(Props)]
|
||||||
pub struct NumberProps<'ctx, F>
|
pub struct NumberProps<'ctx, F>
|
||||||
@ -39,36 +227,26 @@ where
|
|||||||
min,
|
min,
|
||||||
counter,
|
counter,
|
||||||
} = props;
|
} = 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 id = name.clone();
|
||||||
let inc_target_id = id.clone();
|
create_effect(cx, move || {
|
||||||
let dec_target_id = id.clone();
|
let new_count = *counter.get();
|
||||||
let min_field = format!("{}", min);
|
debug!(new_count, "COUNTS: Updating spinner with new value");
|
||||||
|
if let Some(el) = window().unwrap().document().unwrap().get_element_by_id(id.as_str()) {
|
||||||
view! {cx,
|
debug!("COUNTS: found element");
|
||||||
div() {
|
el.set_attribute("val", new_count.to_string().as_str()).unwrap();
|
||||||
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");
|
|
||||||
}
|
|
||||||
}) { "▼" }
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
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");
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -52,20 +52,20 @@ pub fn CategoryGroup<'ctx, G: Html>(
|
|||||||
});
|
});
|
||||||
view! {cx,
|
view! {cx,
|
||||||
h2 { (category) }
|
h2 { (category) }
|
||||||
table(class="recipe_selector no-print") {
|
div(class="recipe_selector no-print") {
|
||||||
(View::new_fragment(
|
(View::new_fragment(
|
||||||
rows.get().iter().cloned().map(|r| {
|
rows.get().iter().cloned().map(|r| {
|
||||||
view ! {cx,
|
view ! {cx,
|
||||||
tr { Keyed(
|
Keyed(
|
||||||
iterable=r,
|
iterable=r,
|
||||||
view=move |cx, sig| {
|
view=move |cx, sig| {
|
||||||
let title = create_memo(cx, move || sig.get().1.title.clone());
|
let title = create_memo(cx, move || sig.get().1.title.clone());
|
||||||
view! {cx,
|
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(),
|
key=|sig| sig.get().0.to_owned(),
|
||||||
)}
|
)
|
||||||
}
|
}
|
||||||
}).collect()
|
}).collect()
|
||||||
))
|
))
|
||||||
|
@ -65,12 +65,10 @@ pub fn RecipeSelection<'ctx, G: Html>(
|
|||||||
let name = format!("recipe_id:{}", id);
|
let name = format!("recipe_id:{}", id);
|
||||||
let for_id = name.clone();
|
let for_id = name.clone();
|
||||||
view! {cx,
|
view! {cx,
|
||||||
div() {
|
label(for=for_id) { a(href=href) { (*title) } }
|
||||||
label(for=for_id) { a(href=href) { (*title) } }
|
NumberField(name=name, counter=count, min=0.0, on_change=Some(move |_| {
|
||||||
NumberField(name=name, counter=count, min=0.0, on_change=Some(move |_| {
|
debug!(idx=%id, count=%(*count.get_untracked()), "setting recipe count");
|
||||||
debug!(idx=%id, count=%(*count.get_untracked()), "setting recipe count");
|
sh.dispatch(cx, Message::UpdateRecipeCount(id.as_ref().clone(), *count.get_untracked() as usize));
|
||||||
sh.dispatch(cx, Message::UpdateRecipeCount(id.as_ref().clone(), *count.get_untracked() as usize));
|
}))
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
|
||||||
}
|
|
@ -23,6 +23,8 @@
|
|||||||
--notification-font-size: calc(var(--font-size) / 2);
|
--notification-font-size: calc(var(--font-size) / 2);
|
||||||
--error-message-color: rgba(255, 98, 0, 0.797);
|
--error-message-color: rgba(255, 98, 0, 0.797);
|
||||||
--error-message-bg: grey;
|
--error-message-bg: grey;
|
||||||
|
--border-width: 2px;
|
||||||
|
--cell-margin: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
@ -126,4 +128,25 @@ nav>h1 {
|
|||||||
to {
|
to {
|
||||||
opacity: 0
|
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;
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user