mirror of https://github.com/yewstack/yew
Add Function Components Example (#2088)
* feat(examples): add function components todo example * chore(examples): apply feedback for function components example * chore(examples): apply more feedback for function components example * feat(examples): implement custom hook for edit boolean toggle * chore(examples): prep for merge, add more documentation * chore(examples): add more descriptive comment to function component hook, fix cargo.toml
This commit is contained in:
parent
35e1ba60aa
commit
05728e1001
|
@ -15,6 +15,7 @@ members = [
|
|||
"examples/crm",
|
||||
"examples/dyn_create_destroy_apps",
|
||||
"examples/file_upload",
|
||||
"examples/function_todomvc",
|
||||
"examples/futures",
|
||||
"examples/game_of_life",
|
||||
"examples/inner_html",
|
||||
|
|
|
@ -33,6 +33,7 @@ As an example, check out the TodoMVC example here: <https://examples.yew.rs/todo
|
|||
| [crm](crm) | Shallow customer relationship management tool |
|
||||
| [dyn_create_destroy_apps](dyn_create_destroy_apps) | Uses the function `start_app_in_element` and the `AppHandle` struct to dynamically create and delete Yew apps |
|
||||
| [file_upload](file_upload) | Uses the `gloo::file` to read the content of user uploaded files |
|
||||
| [function_todomvc](function_todomvc) | Implementation of [TodoMVC](http://todomvc.com/) using function components and hooks. |
|
||||
| [futures](futures) | Demonstrates how you can use futures and async code with Yew. Features a Markdown renderer. |
|
||||
| [game_of_life](game_of_life) | Implementation of [Conway's Game of Life](https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life) |
|
||||
| [inner_html](inner_html) | Embeds an external document as raw HTML by manually managing the element |
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "function_todomvc"
|
||||
version = "0.1.0"
|
||||
authors = ["Drew Hutton <drew.hutton@pm.me>"]
|
||||
edition = "2018"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
strum = "0.21"
|
||||
strum_macros = "0.21"
|
||||
gloo = "0.3"
|
||||
yew = { path = "../../packages/yew" }
|
||||
|
||||
[dependencies.web-sys]
|
||||
version = "0.3"
|
||||
features = [
|
||||
"HtmlInputElement",
|
||||
]
|
|
@ -0,0 +1,15 @@
|
|||
# TodoMVC Example
|
||||
|
||||
[](https://examples.yew.rs/function_todomvc)
|
||||
|
||||
This is an implementation of [TodoMVC](http://todomvc.com/) for Yew using function components and hooks.
|
||||
|
||||
## Concepts
|
||||
|
||||
- Uses [`function_components`](https://yew.rs/next/concepts/function-components)
|
||||
- Uses [`gloo_storage`](https://gloo-rs.web.app/docs/storage) to persist the state
|
||||
|
||||
## Improvements
|
||||
|
||||
- Use `yew-router` for the hash based routing
|
||||
- Clean up the code
|
|
@ -0,0 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Yew • Function TodoMVC</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/todomvc-common@1.0.5/base.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/todomvc-app-css@2.3.0/index.css"
|
||||
/>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
|
@ -0,0 +1,4 @@
|
|||
pub mod entry;
|
||||
pub mod filter;
|
||||
pub mod header_input;
|
||||
pub mod info_footer;
|
|
@ -0,0 +1,117 @@
|
|||
use crate::hooks::use_bool_toggle::use_bool_toggle;
|
||||
use crate::state::Entry as Item;
|
||||
use web_sys::{HtmlInputElement, MouseEvent};
|
||||
use yew::events::{Event, FocusEvent, KeyboardEvent};
|
||||
use yew::{function_component, html, Callback, Classes, Properties, TargetCast};
|
||||
|
||||
#[derive(PartialEq, Properties, Clone)]
|
||||
pub struct EntryProps {
|
||||
pub entry: Item,
|
||||
pub ontoggle: Callback<usize>,
|
||||
pub onremove: Callback<usize>,
|
||||
pub onedit: Callback<(usize, String)>,
|
||||
}
|
||||
|
||||
#[function_component(Entry)]
|
||||
pub fn entry(props: &EntryProps) -> Html {
|
||||
let id = props.entry.id;
|
||||
let mut class = Classes::from("todo");
|
||||
|
||||
// We use the `use_bool_toggle` hook and set the default value to `false`
|
||||
// as the default we are not editing the the entry. When we want to edit the
|
||||
// entry we can call the toggle method on the `UseBoolToggleHandle`
|
||||
// which will trigger a re-render with the toggle value being `true` for that
|
||||
// render and after that render the value of toggle will be flipped back to
|
||||
// its default (`false`).
|
||||
// We are relying on the behavior of `onblur` and `onkeypress` to cause
|
||||
// another render so that this component will render again with the
|
||||
// default value of toggle.
|
||||
let edit_toggle = use_bool_toggle(false);
|
||||
let is_editing = *edit_toggle;
|
||||
|
||||
if is_editing {
|
||||
class.push("editing");
|
||||
}
|
||||
|
||||
if props.entry.completed {
|
||||
class.push("completed");
|
||||
}
|
||||
|
||||
html! {
|
||||
<li {class}>
|
||||
<div class="view">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle"
|
||||
checked={props.entry.completed}
|
||||
onclick={props.ontoggle.reform(move |_| id)}
|
||||
/>
|
||||
<label ondblclick={Callback::once(move |_| {
|
||||
edit_toggle.toggle();
|
||||
})}>
|
||||
{ &props.entry.description }
|
||||
</label>
|
||||
<button class="destroy" onclick={props.onremove.reform(move |_| id)} />
|
||||
</div>
|
||||
<EntryEdit entry={props.entry.clone()} onedit={props.onedit.clone()} editing={is_editing} />
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Properties, Clone)]
|
||||
pub struct EntryEditProps {
|
||||
pub entry: Item,
|
||||
pub onedit: Callback<(usize, String)>,
|
||||
pub editing: bool,
|
||||
}
|
||||
|
||||
#[function_component(EntryEdit)]
|
||||
pub fn entry_edit(props: &EntryEditProps) -> Html {
|
||||
let id = props.entry.id;
|
||||
|
||||
let target_input_value = |e: &Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
input.value()
|
||||
};
|
||||
|
||||
let onblur = {
|
||||
let edit = props.onedit.clone();
|
||||
|
||||
move |e: FocusEvent| {
|
||||
let value = target_input_value(&e);
|
||||
edit.emit((id, value))
|
||||
}
|
||||
};
|
||||
|
||||
let onkeypress = {
|
||||
let edit = props.onedit.clone();
|
||||
|
||||
move |e: KeyboardEvent| {
|
||||
if e.key() == "Enter" {
|
||||
let value = target_input_value(&e);
|
||||
edit.emit((id, value))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let onmouseover = |e: MouseEvent| {
|
||||
e.target_unchecked_into::<HtmlInputElement>()
|
||||
.focus()
|
||||
.unwrap_or_default();
|
||||
};
|
||||
|
||||
if props.editing {
|
||||
html! {
|
||||
<input
|
||||
class="edit"
|
||||
type="text"
|
||||
value={props.entry.description.clone()}
|
||||
{onmouseover}
|
||||
{onblur}
|
||||
{onkeypress}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! { <input type="hidden" /> }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
use crate::state::Filter as FilterEnum;
|
||||
use yew::{function_component, html, Callback, Properties};
|
||||
|
||||
#[derive(PartialEq, Properties)]
|
||||
pub struct FilterProps {
|
||||
pub filter: FilterEnum,
|
||||
pub selected: bool,
|
||||
pub onset_filter: Callback<FilterEnum>,
|
||||
}
|
||||
|
||||
#[function_component(Filter)]
|
||||
pub fn filter(props: &FilterProps) -> Html {
|
||||
let filter = props.filter;
|
||||
|
||||
let cls = if props.selected {
|
||||
"selected"
|
||||
} else {
|
||||
"not-selected"
|
||||
};
|
||||
|
||||
html! {
|
||||
<li>
|
||||
<a class={cls}
|
||||
href={props.filter.as_href()}
|
||||
onclick={props.onset_filter.reform(move |_| filter)}
|
||||
>
|
||||
{ props.filter }
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
use web_sys::HtmlInputElement;
|
||||
use yew::events::KeyboardEvent;
|
||||
use yew::{function_component, html, Callback, Properties, TargetCast};
|
||||
|
||||
#[derive(PartialEq, Properties, Clone)]
|
||||
pub struct HeaderInputProps {
|
||||
pub onadd: Callback<String>,
|
||||
}
|
||||
|
||||
#[function_component(HeaderInput)]
|
||||
pub fn header_input(props: &HeaderInputProps) -> Html {
|
||||
let onkeypress = {
|
||||
let onadd = props.onadd.clone();
|
||||
|
||||
move |e: KeyboardEvent| {
|
||||
if e.key() == "Enter" {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
let value = input.value();
|
||||
|
||||
input.set_value("");
|
||||
onadd.emit(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
html! {
|
||||
<input
|
||||
class="new-todo"
|
||||
placeholder="What needs to be done?"
|
||||
{onkeypress}
|
||||
/>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
use yew::{function_component, html};
|
||||
|
||||
#[function_component(InfoFooter)]
|
||||
pub fn info_footer() -> Html {
|
||||
html! {
|
||||
<footer class="info">
|
||||
<p>{ "Double-click to edit a todo" }</p>
|
||||
<p>{ "Written by " }<a href="https://github.com/Yoroshikun/" target="_blank">{ "Drew Hutton <Yoroshi>" }</a></p>
|
||||
<p>{ "Part of " }<a href="http://todomvc.com/" target="_blank">{ "TodoMVC" }</a></p>
|
||||
</footer>
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
pub mod use_bool_toggle;
|
|
@ -0,0 +1,70 @@
|
|||
use std::ops::Deref;
|
||||
use std::rc::Rc;
|
||||
use yew::functional::use_hook;
|
||||
|
||||
pub struct UseBoolToggleHandle {
|
||||
value: bool,
|
||||
toggle: Rc<dyn Fn()>,
|
||||
}
|
||||
|
||||
impl UseBoolToggleHandle {
|
||||
pub fn toggle(self) {
|
||||
(self.toggle)()
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for UseBoolToggleHandle {
|
||||
type Target = bool;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.value
|
||||
}
|
||||
}
|
||||
|
||||
/// This hook can be used to cause a re-render with the non-default value, which is
|
||||
/// then reset to the default value after that render.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `default` - The default value.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use crate::hooks::use_bool_toggle::use_bool_toggle;
|
||||
/// ...
|
||||
/// let value = use_bool_toggle(false);
|
||||
/// ...
|
||||
/// <button onclick={Callback::once(move |_| {
|
||||
/// value.toggle();
|
||||
/// // This will toggle the value to true.
|
||||
/// // Then render.
|
||||
/// // Post render it will toggle back to false skipping the render.
|
||||
/// })}>
|
||||
/// ...
|
||||
/// ```
|
||||
pub fn use_bool_toggle(default: bool) -> UseBoolToggleHandle {
|
||||
use_hook(
|
||||
|| default,
|
||||
move |hook, updater| {
|
||||
updater.post_render(move |state: &mut bool| {
|
||||
if *state != default {
|
||||
*state = default;
|
||||
}
|
||||
false
|
||||
});
|
||||
|
||||
let toggle = Rc::new(move || {
|
||||
updater.callback(move |st: &mut bool| {
|
||||
*st = !*st;
|
||||
true
|
||||
})
|
||||
});
|
||||
|
||||
UseBoolToggleHandle {
|
||||
value: *hook,
|
||||
toggle,
|
||||
}
|
||||
},
|
||||
|_| {},
|
||||
)
|
||||
}
|
|
@ -0,0 +1,236 @@
|
|||
use gloo::storage::{LocalStorage, Storage};
|
||||
use state::{Entry, Filter, State};
|
||||
use std::rc::Rc;
|
||||
use strum::IntoEnumIterator;
|
||||
use yew::{classes, function_component, html, use_effect_with_deps, use_reducer, Callback};
|
||||
|
||||
mod components;
|
||||
mod hooks;
|
||||
mod state;
|
||||
|
||||
use components::{
|
||||
entry::Entry as EntryItem, filter::Filter as FilterItem, header_input::HeaderInput,
|
||||
info_footer::InfoFooter,
|
||||
};
|
||||
|
||||
pub enum Action {
|
||||
Add(String),
|
||||
Edit((usize, String)),
|
||||
Remove(usize),
|
||||
SetFilter(Filter),
|
||||
ToggleAll,
|
||||
Toggle(usize),
|
||||
ClearCompleted,
|
||||
}
|
||||
|
||||
const KEY: &str = "yew.functiontodomvc.self";
|
||||
|
||||
#[function_component(App)]
|
||||
fn app() -> Html {
|
||||
let state = use_reducer(
|
||||
|prev: Rc<State>, action: Action| match action {
|
||||
Action::Add(description) => {
|
||||
let mut entries = prev.entries.clone();
|
||||
entries.push(Entry {
|
||||
id: entries.last().map(|entry| entry.id + 1).unwrap_or(1),
|
||||
description,
|
||||
completed: false,
|
||||
});
|
||||
State {
|
||||
entries,
|
||||
filter: prev.filter,
|
||||
}
|
||||
}
|
||||
Action::Remove(id) => {
|
||||
let mut entries = prev.entries.clone();
|
||||
entries.retain(|entry| entry.id != id);
|
||||
State {
|
||||
entries,
|
||||
filter: prev.filter,
|
||||
}
|
||||
}
|
||||
Action::Toggle(id) => {
|
||||
let mut entries = prev.entries.clone();
|
||||
let entry = entries.iter_mut().find(|entry| entry.id == id);
|
||||
if let Some(entry) = entry {
|
||||
entry.completed = !entry.completed;
|
||||
}
|
||||
State {
|
||||
entries,
|
||||
filter: prev.filter,
|
||||
}
|
||||
}
|
||||
Action::Edit((id, description)) => {
|
||||
let mut entries = prev.entries.clone();
|
||||
|
||||
if description.is_empty() {
|
||||
entries.retain(|entry| entry.id != id)
|
||||
}
|
||||
|
||||
let entry = entries.iter_mut().find(|entry| entry.id == id);
|
||||
if let Some(entry) = entry {
|
||||
entry.description = description;
|
||||
}
|
||||
State {
|
||||
entries,
|
||||
filter: prev.filter,
|
||||
}
|
||||
}
|
||||
Action::ToggleAll => {
|
||||
let mut entries = prev.entries.clone();
|
||||
for entry in &mut entries {
|
||||
if prev.filter.fits(entry) {
|
||||
entry.completed = !entry.completed;
|
||||
}
|
||||
}
|
||||
State {
|
||||
entries,
|
||||
filter: prev.filter,
|
||||
}
|
||||
}
|
||||
Action::ClearCompleted => {
|
||||
let mut entries = prev.entries.clone();
|
||||
entries.retain(|e| Filter::Active.fits(e));
|
||||
State {
|
||||
entries,
|
||||
filter: prev.filter,
|
||||
}
|
||||
}
|
||||
Action::SetFilter(filter) => State {
|
||||
filter,
|
||||
entries: prev.entries.clone(),
|
||||
},
|
||||
},
|
||||
// Initial state
|
||||
State {
|
||||
entries: LocalStorage::get(KEY).unwrap_or_else(|_| vec![]),
|
||||
filter: Filter::All, // TODO: get from uri
|
||||
},
|
||||
);
|
||||
|
||||
// Effect
|
||||
use_effect_with_deps(
|
||||
move |state| {
|
||||
LocalStorage::set(KEY, &state.clone().entries).expect("failed to set");
|
||||
|| ()
|
||||
},
|
||||
state.clone(),
|
||||
);
|
||||
|
||||
// Callbacks
|
||||
let onremove = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |id: usize| state.dispatch(Action::Remove(id)))
|
||||
};
|
||||
|
||||
let ontoggle = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |id: usize| state.dispatch(Action::Toggle(id)))
|
||||
};
|
||||
|
||||
let ontoggle_all = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| state.dispatch(Action::ToggleAll))
|
||||
};
|
||||
|
||||
let onclear_completed = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| state.dispatch(Action::ClearCompleted))
|
||||
};
|
||||
|
||||
let onedit = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |(id, value): (usize, String)| {
|
||||
state.dispatch(Action::Edit((id, value)));
|
||||
})
|
||||
};
|
||||
|
||||
let onadd = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |value: String| {
|
||||
state.dispatch(Action::Add(value));
|
||||
})
|
||||
};
|
||||
|
||||
let onset_filter = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |filter: Filter| {
|
||||
state.dispatch(Action::SetFilter(filter));
|
||||
})
|
||||
};
|
||||
|
||||
// Helpers
|
||||
let completed = state
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| Filter::Completed.fits(entry))
|
||||
.count();
|
||||
|
||||
let is_all_completed = state
|
||||
.entries
|
||||
.iter()
|
||||
.all(|e| state.filter.fits(e) & e.completed);
|
||||
|
||||
let total = state.entries.len();
|
||||
|
||||
let hidden_class = if state.entries.is_empty() {
|
||||
"hidden"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="todomvc-wrapper">
|
||||
<section class="todoapp">
|
||||
<header class="header">
|
||||
<h1>{ "todos" }</h1>
|
||||
<HeaderInput {onadd} />
|
||||
</header>
|
||||
<section class={classes!("main", hidden_class)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle-all"
|
||||
id="toggle-all"
|
||||
checked={is_all_completed}
|
||||
onclick={ontoggle_all}
|
||||
/>
|
||||
<label for="toggle-all" />
|
||||
<ul class="todo-list">
|
||||
{ for state.entries.iter().filter(|e| state.filter.fits(e)).cloned().map(|entry|
|
||||
html! {
|
||||
<EntryItem {entry}
|
||||
ontoggle={ontoggle.clone()}
|
||||
onremove={onremove.clone()}
|
||||
onedit={onedit.clone()}
|
||||
/>
|
||||
}) }
|
||||
</ul>
|
||||
</section>
|
||||
<footer class={classes!("footer", hidden_class)}>
|
||||
<span class="todo-count">
|
||||
<strong>{ total }</strong>
|
||||
{ " item(s) left" }
|
||||
</span>
|
||||
<ul class="filters">
|
||||
{ for Filter::iter().map(|filter| {
|
||||
html! {
|
||||
<FilterItem {filter}
|
||||
selected={state.filter == filter}
|
||||
onset_filter={onset_filter.clone()}
|
||||
/>
|
||||
}
|
||||
}) }
|
||||
</ul>
|
||||
<button class="clear-completed" onclick={onclear_completed}>
|
||||
{ format!("Clear completed ({})", completed) }
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
<InfoFooter />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
yew::start_app::<App>();
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use strum_macros::{EnumIter, ToString};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct State {
|
||||
pub entries: Vec<Entry>,
|
||||
pub filter: Filter,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Entry {
|
||||
pub id: usize,
|
||||
pub description: String,
|
||||
pub completed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, EnumIter, ToString, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Filter {
|
||||
All,
|
||||
Active,
|
||||
Completed,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
pub fn fits(&self, entry: &Entry) -> bool {
|
||||
match *self {
|
||||
Filter::All => true,
|
||||
Filter::Active => !entry.completed,
|
||||
Filter::Completed => entry.completed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_href(&self) -> &'static str {
|
||||
match self {
|
||||
Filter::All => "#/",
|
||||
Filter::Active => "#/active",
|
||||
Filter::Completed => "#/completed",
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue