日本語

Lists

A Vec field is a list. each renders an element per item in the browser, and list statements change it without a request.

Rendering a list

each takes the list and a function that builds the element for one item. The function runs once on the server to build a template, receiving an Item that stands for whichever item the browser is rendering. item.at(field) reads a field of the item, item.index() its position, and item.value() the item itself.

use hypersig::html::{each, each_keyed, li, span, ul};
use hypersig::Signals;
use serde::{Deserialize, Serialize};

#[derive(Signals, Serialize, Deserialize)]
struct Track {
    id: i32,
    title: String,
}

#[derive(Signals, Serialize, Deserialize)]
struct Playlist {
    tracks: Vec<Track>,
    tags: Vec<String>,
}

// Matched to their elements by position.
let tags = ul::<Playlist>().child(each(Playlist::tags(), |tag| li().bind_text(tag)));

// Matched by id, so an item keeps its element when the list is reordered.
let tracks = ul::<Playlist>().child(each_keyed(
    Playlist::tracks(),
    |track| track.at(Track::id()),
    |track| {
        li().child(span().bind_text((track.index() + 1).to_text()))
            .child(span().bind_text(track.at(Track::title())))
    },
));

each matches items to elements by position. each_keyed matches them by a key, so an item keeps its element when the list is reordered or an item before it is removed, and focus and the state of its inputs move with it.

Changing a list

use hypersig::html::{button, div, p};
use hypersig::{Expr, Signals};
use serde::{Deserialize, Serialize};

#[derive(Signals, Serialize, Deserialize, Clone)]
struct Task {
    title: String,
    done: bool,
    priority: i32,
}

#[derive(Signals, Serialize, Deserialize)]
struct Board {
    tasks: Vec<Task>,
}

let tasks = Board::tasks();
let new_task = Task { title: "Untitled".into(), done: false, priority: 0 };

let toolbar = div::<Board>()
    .child(button().on_click(tasks.push(Expr::lit(&new_task))).text("Add"))
    .child(button().on_click(tasks.set_all(Task::done(), true)).text("Finish all"))
    .child(button().on_click(tasks.retain(|task| !task.at(Task::done()))).text("Clear done"))
    .child(button().on_click(tasks.sort_by(|task| task.at(Task::priority()))).text("Sort"))
    .child(p().bind_text(tasks.count(|task| task.at(Task::done())).to_text()))
    .child(p().show(tasks.is_empty()).text("Nothing to do."));
KindMethods on a list signal
Statementspush, append, extend, remove_at, clear, retain, swap, sort_by, set_all, update_where
Expressionslen, is_empty, nth, map, count, sum_of, max_of, min_of
On one itemitem.remove(), and set on item.at(field)

Item signals stay in the browser

A field of an item is a Sig<S, T, Local>, whose path is resolved per item in the browser. It has no patch method, so the server cannot address one item's field by position. The server replaces the whole list with a patch of the list field, or re-renders a part of the page as a fragment, which Talking to the server covers.

A to-do list

The demo builds a new item with Expr::lit(&blank).with(...), which copies a constant struct and replaces two fields with expressions. The count of open items is an expression over the list.

Result

todos.rs
use hypersig::html::{div, each_keyed, form, input, label, li, p, span, ul};
use hypersig::style::Style;
use hypersig::style::pattern::{hstack, stack};
use hypersig::style::value::{AlignItems, Display, JustifyContent, rem};
use hypersig::{Expr, Page, Signals, Stmt};
use hypersig_ui::{ButtonSize, ButtonVariant, TextField, button, submit_button};
use serde::{Deserialize, Serialize};

#[derive(Signals, Serialize, Deserialize)]
pub struct Todo {
    id: i32,
    title: String,
    done: bool,
}

#[derive(Signals, Serialize, Deserialize)]
pub struct Todos {
    draft: String,
    next_id: i32,
    items: Vec<Todo>,
}

pub fn page() -> Page<Todos> {
    let draft = Todos::draft();
    let next_id = Todos::next_id();
    let items = Todos::items();
    let blank = Todo {
        id: 0,
        title: String::new(),
        done: false,
    };
    let add = items
        .push(
            Expr::lit(&blank)
                .with(Todo::id(), next_id)
                .with(Todo::title(), draft.trim()),
        )
        .then(next_id.set(next_id + 1))
        .then(draft.set(""));

    let initial = Todos {
        draft: String::new(),
        next_id: 2,
        items: vec![
            Todo {
                id: 0,
                title: "Read the guide"
                    .into(),
                done: true,
            },
            Todo {
                id: 1,
                title: "Write a page"
                    .into(),
                done: false,
            },
        ],
    };
    Page::new(
        "Todos",
        initial,
    )
    .child(
        div()
            .css(stack(rem(1)).max_width(rem(24)))
            .child(
                form()
                    .on_submit(Stmt::when(!draft.trim().is_empty(), add))
                    .css(hstack(rem(0.5)).align_items(AlignItems::End))
                    .child(div().css(Style::new().flex_grow(1.0)).child(TextField::new(
                        "New item",
                        draft,
                    )))
                    .child(
                        submit_button(ButtonVariant::Default, ButtonSize::Default).text("Add"),
                    ),
            )
            .child(ul().css(stack(rem(0.25))).child(each_keyed(
                items,
                |todo| todo.at(Todo::id()),
                |todo| {
                    li().css(hstack(rem(0.5)).justify_content(JustifyContent::SpaceBetween))
                        .child(
                            label()
                                .css(hstack(rem(0.5)))
                                .child(input().attr("type", "checkbox").bind(todo.at(Todo::done())))
                                .child(span().bind_text(todo.at(Todo::title()))),
                        )
                        .child(
                            button(ButtonVariant::Ghost, ButtonSize::Sm)
                                .on_click(todo.remove())
                                .text("Remove"),
                        )
                },
            )))
            .child(
                div()
                    .css(
                        Style::new()
                            .display(Display::Flex)
                            .justify_content(JustifyContent::SpaceBetween)
                            .align_items(AlignItems::Center),
                    )
                    .child(
                        p().bind_text(items.count(|todo| !todo.at(Todo::done())).to_text().concat(
                            " left",
                        )),
                    )
                    .child(
                        button(ButtonVariant::Outline, ButtonSize::Sm)
                            .on_click(items.retain(|todo| !todo.at(Todo::done())))
                            .text("Clear done"),
                    ),
            ),
    )
}