diff --git a/examples/form.rs b/examples/form.rs new file mode 100644 index 00000000..b7a6c93b --- /dev/null +++ b/examples/form.rs @@ -0,0 +1,80 @@ +//! Drive the renderer from Dioxus + +use dioxus::prelude::*; + +fn main() { + dioxus_blitz::launch(app); +} + +fn app() -> Element { + let mut checkbox_checked = use_signal(|| false); + + rsx! { + div { + class: "container", + style { {CSS} } + form { + div { + input { + type: "checkbox", + id: "check1", + name: "check1", + value: "check1", + checked: "{checkbox_checked}", + oninput: move |ev| { + dbg!(ev); + checkbox_checked.set(!checkbox_checked()); + }, + } + label { + r#for: "check1", + "Checkbox 1 (controlled)" + } + } + div { + label { + input { + type: "checkbox", + name: "check2", + value: "check2", + } + "Checkbox 2 (uncontrolled)" + } + } + } + div { "Checkbox 1 checked: {checkbox_checked}" } + } + } +} + +const CSS: &str = r#" + +.container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100vh; + width: 100vw; +} + + +form { + margin: 12px 0; + display: block; +} + +form > div { + margin: 8px 0; +} + +label { + display: inline-block; +} + +input { + /* Should be accent-color */ + color: #0000cc; +} + +"#; diff --git a/packages/blitz/src/renderer/render.rs b/packages/blitz/src/renderer/render.rs index 07079dea..d017e415 100644 --- a/packages/blitz/src/renderer/render.rs +++ b/packages/blitz/src/renderer/render.rs @@ -40,6 +40,7 @@ use style::{ use image::{imageops::FilterType, DynamicImage}; use parley::layout::PositionedLayoutItem; use taffy::prelude::Layout; +use vello::kurbo::{BezPath, Cap, Join}; use vello::{ kurbo::{Affine, Point, Rect, Shape, Stroke, Vec2}, peniko::{self, Brush, Color, Fill, Mix}, @@ -362,6 +363,7 @@ impl<'dom> VelloSceneGenerator<'dom> { cx.stroke_devtools(scene); cx.draw_image(scene); cx.draw_svg(scene); + cx.draw_input(scene); // Render the text in text inputs if let Some(input_data) = cx.text_input { @@ -441,7 +443,7 @@ impl<'dom> VelloSceneGenerator<'dom> { } } - fn element_cx<'w>(&'w self, element: &'w Node, location: Point) -> ElementCx { + fn element_cx<'w>(&'w self, element: &'w Node, location: Point) -> ElementCx<'w> { let style = element .stylo_element_data .borrow() @@ -1091,4 +1093,61 @@ impl ElementCx<'_> { ) { unimplemented!() } + + fn draw_input(&self, scene: &mut Scene) { + if self.element.local_name() == "input" + && matches!(self.element.attr(local_name!("type")), Some("checkbox")) + { + let checked: bool = self + .element + .attr(local_name!("checked")) + .and_then(|c| c.parse().ok()) + .unwrap_or_default(); + + // TODO this should be coming from css accent-color, but I couldn't find how to retrieve it + let accent_color = self.style.get_inherited_text().color.as_vello(); + + let scale = self + .frame + .outer_rect + .width() + .min(self.frame.outer_rect.height()) + / 16.0; + + let frame = self.frame.outer_rect.to_rounded_rect(scale * 2.0); + + if checked { + scene.fill(Fill::NonZero, self.transform, accent_color, None, &frame); + + //Tick code derived from masonry + let mut path = BezPath::new(); + path.move_to((2.0, 9.0)); + path.line_to((6.0, 13.0)); + path.line_to((14.0, 2.0)); + + path.apply_affine(Affine::scale(scale)); + + let style = Stroke { + width: 2.0 * scale, + join: Join::Round, + miter_limit: 10.0, + start_cap: Cap::Round, + end_cap: Cap::Round, + dash_pattern: Default::default(), + dash_offset: 0.0, + }; + + scene.stroke(&style, self.transform, Color::WHITE, None, &path); + } else { + scene.fill(Fill::NonZero, self.transform, Color::WHITE, None, &frame); + scene.stroke( + &Stroke::default(), + self.transform, + accent_color, + None, + &frame, + ); + } + } + } } diff --git a/packages/dioxus-blitz/src/accessibility.rs b/packages/dioxus-blitz/src/accessibility.rs index 0d08825f..8787a2b5 100644 --- a/packages/dioxus-blitz/src/accessibility.rs +++ b/packages/dioxus-blitz/src/accessibility.rs @@ -71,6 +71,7 @@ impl AccessibilityState { let ty = element_data.attr(local_name!("type")).unwrap_or("text"); match ty { "number" => Role::NumberInput, + "checkbox" => Role::CheckBox, _ => Role::TextInput, } } diff --git a/packages/dioxus-blitz/src/documents/dioxus_document.rs b/packages/dioxus-blitz/src/documents/dioxus_document.rs index 94433fe3..f5239163 100644 --- a/packages/dioxus-blitz/src/documents/dioxus_document.rs +++ b/packages/dioxus-blitz/src/documents/dioxus_document.rs @@ -1,10 +1,10 @@ //! Integration between Dioxus and Blitz -use std::rc::Rc; +use std::{collections::HashMap, rc::Rc}; use blitz_dom::{ - events::EventData, namespace_url, node::Attribute, ns, Atom, Document, DocumentLike, - ElementNodeData, NodeData, QualName, TextNodeData, Viewport, DEFAULT_CSS, + events::EventData, local_name, namespace_url, node::Attribute, ns, Atom, Document, + DocumentLike, ElementNodeData, Node, NodeData, QualName, TextNodeData, Viewport, DEFAULT_CSS, }; use dioxus::{ @@ -12,6 +12,7 @@ use dioxus::{ AttributeValue, ElementId, Template, TemplateAttribute, TemplateNode, VirtualDom, WriteMutations, }, + html::FormValue, prelude::{set_event_converter, PlatformEventData}, }; use futures_util::{pin_mut, FutureExt}; @@ -21,7 +22,7 @@ use style::{ properties::{style_structs::Font, ComputedValues}, }; -use super::event_handler::{NativeClickData, NativeConverter}; +use super::event_handler::{NativeClickData, NativeConverter, NativeFormData}; type NodeId = usize; @@ -111,17 +112,41 @@ impl DocumentLike for DioxusDocument { continue; }; - for attr in element.attrs() { - if attr.name.local.as_ref() == "data-dioxus-id" { - if let Ok(value) = attr.value.parse::() { - let id = ElementId(value); - // let data = dioxus::html::EventData::Mouse() + if let Some(id) = DioxusDocument::dioxus_id(element) { + // let data = dioxus::html::EventData::Mouse() + self.vdom + .handle_event("click", self.click_event_data(), id, true); + //TODO Check for other inputs which trigger input event on click here, eg radio + let triggers_input_event = element.name.local == local_name!("input") + && element.attr(local_name!("type")) == Some("checkbox"); + if triggers_input_event { + let form_data = self.input_event_form_data(&chain, element); + self.vdom.handle_event("input", form_data, id, true); + } + return true; + } - let data = - Rc::new(PlatformEventData::new(Box::new(NativeClickData {}))); - self.vdom.handle_event(event.name(), data, id, true); - return true; + //Clicking labels triggers click, and possibly input event, of bound input + if *element.name.local == *"label" { + let bound_input_elements = self.inner.label_bound_input_elements(*node); + //Filter down bound elements to those which have dioxus id + if let Some((element_data, dioxus_id)) = + bound_input_elements.into_iter().find_map(|n| { + let target_element_data = n.element_data()?; + let dioxus_id = DioxusDocument::dioxus_id(target_element_data)?; + Some((target_element_data, dioxus_id)) + }) + { + self.vdom + .handle_event("click", self.click_event_data(), dioxus_id, true); + //TODO Check for other inputs which trigger input event on click here, eg radio + let triggers_input_event = + element_data.attr(local_name!("type")) == Some("checkbox"); + if triggers_input_event { + let form_data = self.input_event_form_data(&chain, element_data); + self.vdom.handle_event("input", form_data, dioxus_id, true); } + return true; } } } @@ -134,6 +159,80 @@ impl DocumentLike for DioxusDocument { } impl DioxusDocument { + pub fn click_event_data(&self) -> Rc { + Rc::new(PlatformEventData::new(Box::new(NativeClickData {}))) + } + + /// Generate the FormData from an input event + /// Currently only cares about input checkboxes + pub fn input_event_form_data( + &self, + parent_chain: &[usize], + element_node_data: &ElementNodeData, + ) -> Rc { + let parent_form = parent_chain.iter().find_map(|id| { + let node = self.inner.get_node(*id)?; + let element_data = node.element_data()?; + if element_data.name.local == local_name!("form") { + Some(node) + } else { + None + } + }); + let values = if let Some(parent_form) = parent_form { + let mut values = HashMap::::new(); + for form_input in self.input_descendents(parent_form).into_iter() { + // Match html behaviour here. To be included in values: + // - input must have a name + // - if its an input, we only include it if checked + // - if value is not specified, it defaults to 'on' + if let Some(name) = form_input.attr(local_name!("name")) { + if form_input.attr(local_name!("type")) == Some("checkbox") + && form_input.attr(local_name!("checked")) == Some("true") + { + let value = form_input + .attr(local_name!("value")) + .unwrap_or("on") + .to_string(); + values.insert(name.to_string(), FormValue(vec![value])); + } + } + } + values + } else { + Default::default() + }; + let form_data = NativeFormData { + value: element_node_data + .attr(local_name!("value")) + .unwrap_or_default() + .to_string(), + values, + }; + Rc::new(PlatformEventData::new(Box::new(form_data))) + } + + /// Collect all the inputs which are descendents of a given node + fn input_descendents(&self, node: &Node) -> Vec<&Node> { + node.children + .iter() + .flat_map(|id| { + let mut res = Vec::<&Node>::new(); + let Some(n) = self.inner.get_node(*id) else { + return res; + }; + let Some(element_data) = n.element_data() else { + return res; + }; + if element_data.name.local == local_name!("input") { + res.push(n); + } + res.extend(self.input_descendents(n).iter()); + res + }) + .collect() + } + pub fn new(vdom: VirtualDom) -> Self { let viewport = Viewport::new(0, 0, 1.0); let mut doc = Document::new(viewport); @@ -190,6 +289,18 @@ impl DioxusDocument { // dbg!(writer.state); } + fn dioxus_id(element_node_data: &ElementNodeData) -> Option { + Some(ElementId( + element_node_data + .attrs + .iter() + .find(|attr| *attr.name.local == *"data-dioxus-id")? + .value + .parse::() + .ok()?, + )) + } + // pub fn apply_mutations(&mut self) { // // Apply the mutations to the actual dom // let mut writer = MutationWriter { diff --git a/packages/dioxus-blitz/src/documents/event_handler.rs b/packages/dioxus-blitz/src/documents/event_handler.rs index 28d7aafd..2b1621f8 100644 --- a/packages/dioxus-blitz/src/documents/event_handler.rs +++ b/packages/dioxus-blitz/src/documents/event_handler.rs @@ -1,4 +1,9 @@ -use dioxus::prelude::{HtmlEventConverter, PlatformEventData}; +use std::collections::HashMap; + +use dioxus::{ + html::{FormValue, HasFileData, HasFormData}, + prelude::{HtmlEventConverter, PlatformEventData}, +}; #[derive(Clone)] pub struct NativeClickData {} @@ -68,8 +73,9 @@ impl HtmlEventConverter for NativeConverter { todo!() } - fn convert_form_data(&self, _event: &PlatformEventData) -> dioxus::prelude::FormData { - todo!() + fn convert_form_data(&self, event: &PlatformEventData) -> dioxus::prelude::FormData { + let o = event.downcast::().unwrap().clone(); + dioxus::prelude::FormData::from(o) } fn convert_image_data(&self, _event: &PlatformEventData) -> dioxus::prelude::ImageData { @@ -124,3 +130,25 @@ impl HtmlEventConverter for NativeConverter { todo!() } } + +#[derive(Clone, Debug)] +pub struct NativeFormData { + pub value: String, + pub values: HashMap, +} + +impl HasFormData for NativeFormData { + fn as_any(&self) -> &dyn std::any::Any { + self as &dyn std::any::Any + } + + fn value(&self) -> String { + self.value.clone() + } + + fn values(&self) -> HashMap { + self.values.clone() + } +} + +impl HasFileData for NativeFormData {} diff --git a/packages/dom/src/default.css b/packages/dom/src/default.css index c7c050dd..0544e8f5 100644 --- a/packages/dom/src/default.css +++ b/packages/dom/src/default.css @@ -42,6 +42,11 @@ input { display: inline-block; } +input[type="checkbox"] { + width: 14px; + height: 14px; + margin: 3px 3px 3px 4px; +} /* To ensure http://www.w3.org/TR/REC-html40/struct/dirlang.html#style-bidi: * * "When a block element that does not have a dir attribute is transformed to diff --git a/packages/dom/src/document.rs b/packages/dom/src/document.rs index 8e2d3aaa..66f8a2ea 100644 --- a/packages/dom/src/document.rs +++ b/packages/dom/src/document.rs @@ -1,7 +1,8 @@ use crate::events::{EventData, HitResult, RendererEvent}; -use crate::node::TextBrush; -use crate::{Node, NodeData, TextNodeData, Viewport}; +use crate::node::{Attribute, NodeSpecificData, TextBrush}; +use crate::{ElementNodeData, Node, NodeData, TextNodeData, Viewport}; use app_units::Au; +use html5ever::{local_name, namespace_url, ns, QualName}; use peniko::kurbo; // use quadtree_rs::Quadtree; use parley::editor::{PointerButton, TextEvent}; @@ -124,21 +125,42 @@ impl DocumentLike for Document { assert!(hit.node_id == event.target); let node = &mut self.nodes[hit.node_id]; - let text_input_data = node - .raw_dom_data - .downcast_element_mut() - .and_then(|el| el.text_input_data_mut()); - if text_input_data.is_some() { + let Some(el) = node.raw_dom_data.downcast_element_mut() else { + return true; + }; + + if let NodeSpecificData::TextInput(ref mut text_input_data) = + el.node_specific_data + { let x = hit.x as f64 * self.viewport.scale_f64(); let y = hit.y as f64 * self.viewport.scale_f64(); - text_input_data.unwrap().editor.pointer_down( + text_input_data.editor.pointer_down( kurbo::Point { x, y }, mods, PointerButton::Primary, ); - println!("Clicked {}", hit.node_id); self.set_focus_to(hit.node_id); + } else if el.name.local == local_name!("input") + && matches!(el.attr(local_name!("type")), Some("checkbox")) + { + Document::toggle_checkbox(el); + self.set_focus_to(hit.node_id); + } + // Clicking labels triggers click, and possibly input event, of associated input + else if el.name.local == local_name!("label") { + let node_id = node.id; + if let Some(target_node_id) = self + .label_bound_input_elements(node_id) + .first() + .map(|n| n.id) + { + let target_node = self.get_node_mut(target_node_id).unwrap(); + if let Some(target_element) = target_node.element_data_mut() { + Document::toggle_checkbox(target_element); + } + self.set_focus_to(node_id); + } } } } @@ -243,6 +265,71 @@ impl Document { .or(self.try_root_element().map(|el| el.id)) } + /// Find the label's bound input elements: + /// the element id referenced by the "for" attribute of a given label element + /// or the first input element which is nested in the label + /// Note that although there should only be one bound element, + /// we return all possibilities instead of just the first + /// in order to allow the caller to decide which one is correct + pub fn label_bound_input_elements(&self, label_node_id: usize) -> Vec<&Node> { + let label_node = self.get_node(label_node_id).unwrap(); + let label_element = label_node.element_data().unwrap(); + if let Some(target_element_dom_id) = label_element.attr(local_name!("for")) { + self.tree() + .into_iter() + .filter_map(|(_id, node)| { + let element_data = node.element_data()?; + if element_data.name.local != local_name!("input") { + return None; + } + let id = element_data.id.as_ref()?; + if *id == *target_element_dom_id { + Some(node) + } else { + None + } + }) + .collect() + } else { + label_node + .children + .iter() + .filter_map(|child_id| { + let node = self.get_node(*child_id)?; + let element_data = node.element_data()?; + if element_data.name.local == local_name!("input") { + Some(node) + } else { + None + } + }) + .collect() + } + } + + pub fn toggle_checkbox(el: &mut ElementNodeData) { + let checked_attr_opt = el + .attrs + .iter_mut() + .find(|attr| attr.name.local == local_name!("checked")); + + let checked_attr = if let Some(attr) = checked_attr_opt { + attr + } else { + let attr = Attribute { + name: QualName::new(None, ns!(html), local_name!("checked")), + value: String::from("false"), + }; + el.attrs.push(attr); + el.attrs + .iter_mut() + .find(|attr| attr.name.local == local_name!("checked")) + .unwrap() + }; + let checked = checked_attr.value.parse().unwrap_or(false); + checked_attr.value = (!checked).to_string(); + } + pub fn root_node(&self) -> &Node { &self.nodes[0] } diff --git a/packages/dom/src/layout/mod.rs b/packages/dom/src/layout/mod.rs index 501a47a3..73f05236 100644 --- a/packages/dom/src/layout/mod.rs +++ b/packages/dom/src/layout/mod.rs @@ -144,10 +144,36 @@ impl LayoutPartialTree for Document { // todo: need to handle shadow roots by actually descending into them if *element_data.name.local == *"input" { - // if the input type is hidden, hide it - if let Some("hidden") = element_data.attr(local_name!("type")) { - node.style.display = Display::None; - return taffy::LayoutOutput::HIDDEN; + match element_data.attr(local_name!("type")) { + // if the input type is hidden, hide it + Some("hidden") => { + node.style.display = Display::None; + return taffy::LayoutOutput::HIDDEN; + } + Some("checkbox") => { + return compute_leaf_layout( + inputs, + &node.style, + |_known_size, _available_space| { + let width = node + .style + .size + .width + .resolve_or_zero(inputs.parent_size.width); + let height = node + .style + .size + .height + .resolve_or_zero(inputs.parent_size.height); + let min_size = width.min(height); + taffy::Size { + width: min_size, + height: min_size, + } + }, + ); + } + _ => {} } }