diff --git a/Cargo.lock b/Cargo.lock index 730cd4419..51efaa346 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -718,6 +718,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "components" +version = "0.0.0" +dependencies = [ + "console_error_panic_hook", + "time", + "wasm-bindgen", + "web-sys", + "xilem_web", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -4049,6 +4060,7 @@ checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", "itoa", + "js-sys", "libc", "num-conv", "num_threads", diff --git a/Cargo.toml b/Cargo.toml index 00cee30df..591151fae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "masonry_winit", "xilem_web", + "xilem_web/web_examples/components", "xilem_web/web_examples/counter", "xilem_web/web_examples/counter_custom_element", "xilem_web/web_examples/elm", diff --git a/xilem_web/web_examples/components/Cargo.toml b/xilem_web/web_examples/components/Cargo.toml new file mode 100644 index 000000000..50909d8d3 --- /dev/null +++ b/xilem_web/web_examples/components/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "components" +license.workspace = true +edition.workspace = true +rust-version.workspace = true + +[[bin]] +name = "components" +test = false +doctest = false +doc = false +bench = false + +[lints] +workspace = true + +[dependencies] +console_error_panic_hook = "0.1.7" +time = { workspace = true, features = ["formatting", "local-offset", "macros", "wasm-bindgen"] } +wasm-bindgen = "0.2.108" +web-sys = "0.3.85" +xilem_web = { path = "../.." } diff --git a/xilem_web/web_examples/components/index.html b/xilem_web/web_examples/components/index.html new file mode 100644 index 000000000..f764796ef --- /dev/null +++ b/xilem_web/web_examples/components/index.html @@ -0,0 +1,8 @@ + + + + Components | Xilem Web + + + + diff --git a/xilem_web/web_examples/components/src/card.rs b/xilem_web/web_examples/components/src/card.rs new file mode 100644 index 000000000..1991f3347 --- /dev/null +++ b/xilem_web/web_examples/components/src/card.rs @@ -0,0 +1,44 @@ +// Copyright 2026 the Xilem Authors +// SPDX-License-Identifier: Apache-2.0 + +use xilem_web::{DomFragment, core::map_action, elements::html, interfaces::Element}; + +// The `CardAction` is a composition of +// the variations of the card component +// (only `Toggle` here so far) +// and the generic actions `A` of the child. +pub(crate) enum Action { + Toggle, + Child(A), +} + +impl xilem_web::Action for Action {} + +// This view is generic about its child and its actions. +// It also has no state of its own, +// but only communicates with the parents via the [`CardAction`]. +pub(crate) fn view( + title: &'static str, + collapsed: bool, + content: Child, +) -> impl Element> +where + Child: DomFragment, + State: 'static, + ChildAction: 'static, +{ + let content = map_action( + html::div(content) + .class("content") + .class(collapsed.then_some("hidden")), + |_, msg| Action::Child(msg), + ); + + html::div(( + html::h3(title) + .class("title") + .on_click(|_, _| Action::Toggle), + content, + )) + .class("card") +} diff --git a/xilem_web/web_examples/components/src/date_picker.rs b/xilem_web/web_examples/components/src/date_picker.rs new file mode 100644 index 000000000..c6840d42a --- /dev/null +++ b/xilem_web/web_examples/components/src/date_picker.rs @@ -0,0 +1,166 @@ +// Copyright 2026 the Xilem Authors +// SPDX-License-Identifier: Apache-2.0 + +use time::{Date, Duration, OffsetDateTime}; +use xilem_web::{elements::html, interfaces::Element, modifiers::style}; + +#[derive(Debug, Default)] +pub(crate) struct State { + pub selected: Option, + pub popup_open: bool, + pub shown_month: Option, +} + +pub(crate) enum Action { + DateChanged(Option), + Cancelled, +} + +impl xilem_web::Action for Action {} + +// /// Renders only the calendar popup (always visible, no input field). +// /// Intended for use in overlays where the caller controls positioning and backdrop. +pub(crate) fn view(state: &State) -> impl Element + use<> { + let clear_button = html::button("Clear").on_click(|state: &mut State, _| { + state.selected = None; + state.shown_month = None; + Action::DateChanged(None) + }); + + let today_button = html::button("Today").on_click(|state: &mut State, _| { + let today = today(); + state.selected = Some(today); + state.shown_month = Some(today); + Action::DateChanged(Some(today)) + }); + + let cancel_button = html::button("\u{2716}") + .class("ml-auto") + .on_click(|_: &mut State, _| Action::Cancelled); + + html::div(( + html::div((clear_button, today_button, cancel_button)).class("actions"), + navigation_bar(state), + calendar_grid(state), + )) + .style((!state.popup_open).then_some(style("display", "none"))) + .class("date-picker") +} + +fn navigation_bar(model: &State) -> impl Element + use<> { + let shown = model.shown_month.unwrap_or_else(today); + + let title = format!("{} {}", shown.month(), shown.year()); + + html::div(( + html::button("<").on_click(|model: &mut State, _| { + let cur = model.shown_month.unwrap_or_else(today); + model.shown_month = Some(shift_month(cur, -1)); + }), + html::button("\u{00AB}").on_click(|model: &mut State, _| { + let cur = model.shown_month.unwrap_or_else(today); + model.shown_month = Some(shift_month(cur, -12)); + }), + html::span(title).class("title"), + html::button("\u{00BB}").on_click(|model: &mut State, _| { + let cur = model.shown_month.unwrap_or_else(today); + model.shown_month = Some(shift_month(cur, 12)); + }), + html::button(">").on_click(|model: &mut State, _| { + let cur = model.shown_month.unwrap_or_else(today); + + model.shown_month = Some(shift_month(cur, 1)); + }), + )) + .class("nav-bar") +} + +fn calendar_grid(model: &State) -> impl Element + use<> { + let now = OffsetDateTime::now_local().unwrap(); + let today = Date::from_calendar_date(now.year(), now.month(), now.day()).unwrap(); + let shown_month = model.shown_month.unwrap_or(today); + + let start = month_start(shown_month); + let mut day = start; + + let weeks = (0..6).map(|_| { + let days = (0..7).map(|_| { + let cur_day = day; + day += Duration::days(1); + + let class = if cur_day.month() != shown_month.month() { + Some("not-in-month") + } else if Some(cur_day) == model.selected { + Some("selected") + } else if cur_day == today { + Some("today") + } else { + None + }; + + html::td(cur_day.day().to_string()) + .on_click(move |mdl: &mut State, _event| { + mdl.selected = Some(cur_day); + mdl.popup_open = false; + Action::DateChanged(Some(cur_day)) + }) + .class("day") + .class(class) + }); + + html::tr(days.collect::>()) + }); + + let header = html::tr(WEEKDAYS.iter().map(|d| html::th(*d)).collect::>()); + + html::table((html::thead(header), html::tbody(weeks.collect::>()))).class("calendar") +} + +const WEEKDAYS: [&str; 7] = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]; + +fn month_start(date: Date) -> Date { + let first = Date::from_calendar_date(date.year(), date.month(), 1).unwrap(); + let weekday = first.weekday().number_from_monday(); + first - Duration::days(i64::from(weekday - 1)) +} + +fn shift_month(base: Date, months: i16) -> Date { + let mut year = base.year(); + let mut month = base.month() as i16 + months; + + while month > 12 { + month -= 12; + year += 1; + } + while month < 1 { + month += 12; + year -= 1; + } + + let day = base + .day() + .min(days_in_month(year, u8::try_from(month).unwrap())); + let month = time::Month::try_from(u8::try_from(month).unwrap()).unwrap(); + + Date::from_calendar_date(year, month, day).unwrap() +} + +fn days_in_month(year: i32, month: u8) -> u8 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => { + if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) { + 29 + } else { + 28 + } + } + _ => unreachable!(), + } +} + +fn today() -> Date { + let now = OffsetDateTime::now_local().unwrap(); + Date::from_calendar_date(now.year(), now.month(), now.day()).unwrap() +} diff --git a/xilem_web/web_examples/components/src/main.rs b/xilem_web/web_examples/components/src/main.rs new file mode 100644 index 000000000..0a7a4bbd3 --- /dev/null +++ b/xilem_web/web_examples/components/src/main.rs @@ -0,0 +1,102 @@ +// Copyright 2026 the Xilem Authors +// SPDX-License-Identifier: Apache-2.0 + +//! This example shows how to create generic components. + +use time::{Date, format_description::FormatItem, macros::format_description}; +use xilem_web::{ + App, + core::{MessageResult, map_action, map_message_result, map_state}, + document_body, + elements::html, + interfaces::Element, +}; + +// These modules could also be in an external crate. +mod card; +mod date_picker; + +const DATE_FORMAT: &[FormatItem<'static>] = format_description!("[year]-[day]-[month]"); + +#[derive(Default)] +struct AppState { + clicks: i32, + card_collapsed: bool, + date: Option, + date_picker: date_picker::State, +} + +impl AppState { + fn click(&mut self) { + self.clicks += 1; + } + + fn toggle_card(&mut self) { + self.card_collapsed = !self.card_collapsed; + } + + fn show_date_picker(&mut self) { + self.date_picker.popup_open = true; + } + + fn close_date_picker(&mut self) { + self.date_picker.popup_open = false; + } +} + +fn app_logic(state: &mut AppState) -> impl Element + use<> { + let counter = html::button("click me!").on_click(|state: &mut AppState, _| state.click()); + + let card_content = html::div(("Some content ... ", state.clicks, counter)); + + let card = card::view("Card Example", state.card_collapsed, card_content); + let card = map_action(card, map_card_action); + + let date_input = html::input(()) + .attr( + "value", + state + .date + .map(|date| date.format(DATE_FORMAT).unwrap()) + .unwrap_or_default(), + ) + .class("date") + .on_focus(|state: &mut AppState, _| state.show_date_picker()); + + let date_picker = date_picker::view(&state.date_picker); + let date_picker = map_state(date_picker, |state: &mut AppState| &mut state.date_picker); + let date_picker = map_message_result(date_picker, handle_date_picker_message_result); + + html::div((card, html::div((date_input, date_picker)))) +} + +fn map_card_action(state: &mut AppState, action: card::Action<()>) { + match action { + card::Action::Toggle => state.toggle_card(), + card::Action::Child(()) => {} + } +} + +fn handle_date_picker_message_result( + state: &mut AppState, + message_result: MessageResult, +) -> MessageResult<()> { + let MessageResult::Action(action) = message_result else { + return message_result.map(|_| ()); + }; + match action { + date_picker::Action::DateChanged(date) => { + state.date = date; + state.close_date_picker(); + } + date_picker::Action::Cancelled => { + state.close_date_picker(); + } + } + MessageResult::Action(()) +} + +fn main() { + console_error_panic_hook::set_once(); + App::new(document_body(), AppState::default(), app_logic).run(); +} diff --git a/xilem_web/web_examples/components/style.css b/xilem_web/web_examples/components/style.css new file mode 100644 index 000000000..9913ccf8a --- /dev/null +++ b/xilem_web/web_examples/components/style.css @@ -0,0 +1,98 @@ +html,body { + font-family: sans-serif; +} + +.hidden { + display: none; +} + +.card { + box-shadow: 1px 1px 3px #555; + border-radius: 5px; +} + +.card .title { + cursor: pointer; + margin: 0; + color: #444; + background: #ddd; + border-radius: 5px 5px 0 0; + border-bottom: 1px solid #aaa; + padding: 0.2em 0.5em; +} + +.card .content { + color: #333; + background: #eee; + border-radius: 0 0 5px 5px; + padding: 1em; +} + +.card button { + margin: 0.3em 0.7em; + padding: 0.3em 0.5em; +} + +input.date { + margin: 2em 0; + padding: 0.3em 0.5em; + border-width: 1px; + border-radius: 3px; +} + +.date-picker { + display: grid; + max-width: 300px; + background-color: #eee; + border-width: 1px; + border-radius: 5px; +} + +.date-picker .actions, +.date-picker .nav-bar { + display: flex; + gap: 0.25rem; + padding: 0.25rem; + border-bottom: 1px solid #ccc; +} + +.date-picker .nav-bar .title { + flex: 1; + text-align: center; +} + +.date-picker .actions button, +.date-picker .nav-bar button { + cursor: pointer; + font-weight: bold; +} + +.date-picker .calendar { + margin: 0.25rem; + text-align: center; + font-size: 0.9em; + border-collapse: collapse; +} + +.date-picker .calendar .day { + border-radius: 0.5rem; + width: 1.5rem; + height: 1.75rem; + cursor: pointer; +} + +.date-picker .calendar .day.selected { + background: #0a0; +} + +.date-picker .calendar .day.today { + background: #88f; +} + +.date-picker .calendar .day.not-in-month { + color: #aaa; +} + +.ml-auto { + margin-left: auto; +}