diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0d2d1c9..ee01a37 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -6,37 +6,35 @@ jobs: name: Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions-rs/toolchain@v1 + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable with: - toolchain: stable - target: wasm32-unknown-unknown - default: true + targets: wasm32-unknown-unknown components: clippy, rustfmt - - uses: actions-rs/cargo@v1 - with: - command: clippy - - uses: actions-rs/cargo@v1 - with: - command: fmt - args: -- --check - - uses: actions-rs/cargo@v1 - with: - command: build + - name: Run clippy + run: cargo clippy + - name: Check formatting + run: cargo fmt -- --check + - name: Build + run: cargo build + - name: Install nightly for docs + uses: dtolnay/rust-toolchain@nightly + - name: Build docs (all features) + run: RUSTDOCFLAGS="--cfg docsrs" cargo doc --all-features --no-deps example_basic: name: Example | Basic needs: build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions-rs/toolchain@v1 + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable with: - toolchain: stable - target: wasm32-unknown-unknown - default: true + targets: wasm32-unknown-unknown components: clippy, rustfmt - - name: fetch trunk - run: wget -qO- https://github.com/thedodd/trunk/releases/download/v0.16.0/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -xzf- - - name: build example - run: cd examples/basic && ../../trunk build + - name: Install trunk + uses: taiki-e/install-action@v2 + with: + tool: trunk + - name: Build example + run: cd examples/basic && trunk build diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 5086929..8f3d60a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -9,14 +9,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup | Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup | Rust - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - profile: minimal - override: true + uses: dtolnay/rust-toolchain@stable - name: Build | Publish run: cargo publish --token ${{ secrets.CRATES_IO_TOKEN }} @@ -26,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup | Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup | Create Release Log run: cat CHANGELOG.md | tail -n +7 | head -n 25 > RELEASE_LOG.md diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..000bb2c --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,22 @@ +name: Rust + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose diff --git a/Cargo.toml b/Cargo.toml index a9f221d..f24bed2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,23 +1,25 @@ [package] name = "ybc" -version = "0.4.0" +version = "0.4.2" description = "A Yew component library based on the Bulma CSS framework." -authors = ["Anthony Dodd "] -edition = "2021" +authors = ["Anthony Dodd ", "Konstantin Pupkov "] +edition = "2024" license = "MIT/Apache-2.0" -repository = "https://github.com/thedodd/ybc" +repository = "https://github.com/goodidea-kp/ybc.git" +documentation = "https://docs.rs/ybc" readme = "README.md" categories = ["wasm", "web-programming"] keywords = ["wasm", "web", "bulma", "sass", "yew"] [dependencies] -derive_more = { version = "0.99.17", default-features = false, features = ["display"] } -web-sys = { version = "0.3.61", features = ["Element", "File", "HtmlCollection", "HtmlSelectElement"] } -yew = { version = "0.20.0", features = ["csr"] } -yew-agent = "0.2.0" -yew-router = { version = "0.17.0", optional = true } -wasm-bindgen = "0.2.84" -serde = { version = "1.0.152", features = ["derive"] } +derive_more = { version = "2.0.1", default-features = false, features = ["display"] } +web-sys = { version = "0.3.81", features = ["Element", "File", "HtmlCollection", "HtmlSelectElement"] } +yew = { version = "0.22.0", features = ["csr"] } +yew-agent = "0.4.0" +yew-router = { version = "0.19.0", optional = true } +wasm-bindgen = "0.2" +serde = { version = "1.0.228", features = ["derive"] } +#gloo-console = "0.3.0" [features] default = ["router"] @@ -26,3 +28,5 @@ docinclude = [] # Used only for activating `doc(include="...")` on nightly. [package.metadata.docs.rs] features = ["docinclude"] # Activate `docinclude` during docs.rs build. +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/README.md b/README.md index 03e0442..c120295 100644 --- a/README.md +++ b/README.md @@ -56,12 +56,12 @@ First, add this library to your `Cargo.toml` dependencies. ```toml [dependencies] -ybc = "*" +ybc = { git = "https://github.com/goodidea-kp/ybc.git" } ``` ### add bulma #### add bulma css (no customizations) -This project works perfectly well if you just include the Bulma CSS in your HTML, [as described here](https://bulma.io/documentation/overview/start/). The following link in your HTML head should do the trick: ``. +This project works perfectly well if you just include the Bulma CSS in your HTML, [as described here](https://bulma.io/documentation/overview/start/). The following link in your HTML head should do the trick: ``. #### add bulma sass (allows customization & themes) However, if you want to customize Bulma to match your style guidelines, then you will need to have a copy of the Bulma SASS locally, and then import Bulma after you've defined your customizations, [as described here](https://bulma.io/documentation/customize/). diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml index 1118d5d..a08c008 100644 --- a/examples/basic/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -1,15 +1,15 @@ [package] name = "basic" version = "0.1.0" -authors = ["Anthony Dodd "] +authors = ["Anthony Dodd ", "Konstantin Pupkov "] edition = "2018" [dependencies] console_error_panic_hook = "0.1" -gloo-console = "0.2" +gloo-console = "0.3" wasm-bindgen = "0.2" -ybc = { path = "../../" } -yew = "0.20" +ybc = { path = "../.." } +yew = "0.22" [features] default = [] diff --git a/examples/basic/index.html b/examples/basic/index.html index fdc3571..510f684 100644 --- a/examples/basic/index.html +++ b/examples/basic/index.html @@ -1,19 +1,26 @@ - + Trunk | Yew | YBC - - + + + + + + + + + diff --git a/examples/basic/src/chatgpt.svg b/examples/basic/src/chatgpt.svg new file mode 100644 index 0000000..7f2cf6a --- /dev/null +++ b/examples/basic/src/chatgpt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/basic/src/index.scss b/examples/basic/src/index.scss index 35a1942..364e6c6 100644 --- a/examples/basic/src/index.scss +++ b/examples/basic/src/index.scss @@ -1,3 +1,10 @@ @charset "utf-8"; html {} + +.ribbon { + position:absolute; + top:0; + right:0; + z-index:1; +} \ No newline at end of file diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs index 3c8e93c..08044e3 100644 --- a/examples/basic/src/main.rs +++ b/examples/basic/src/main.rs @@ -1,14 +1,38 @@ #![recursion_limit = "1024"] use console_error_panic_hook::set_once as set_panic_hook; +use std::rc::Rc; use wasm_bindgen::prelude::*; +use ybc::Calendar; use ybc::TileCtx::{Ancestor, Child, Parent}; use yew::prelude::*; -#[function_component(App)] +use ybc::NavBurgerCloserState; + +#[component(App)] pub fn app() -> Html { + let state = Rc::new(NavBurgerCloserState { total_clicks: 0 }); + let cb_date_changed = Callback::from(|date: String| { + gloo_console::log!("Date changed: {}", date); + }); + + let cb_on_update = Callback::from(|tag: String| { + gloo_console::log!("Tag updated: {}", tag); + }); + let cb_on_remove = Callback::from(|tag: String| { + gloo_console::log!("Tag removed: {}", tag); + }); + let calendar_departure_date = html! { + + }; + let cb_on_text_update = Callback::from(|tag: String| { + gloo_console::log!("Tex updated: {}", tag); + }); + let items: UseStateHandle> = use_state(|| vec!["Apple".to_string(), "Banana".to_string(), "Cherry".to_string()]); + html! { <> + > context={state}> Html { navend={html!{ <> - + {"Trunk"} @@ -31,13 +55,14 @@ pub fn app() -> Html { - + {"YBC"} }} /> + >> Html { {"YBC"}

{"A Yew component library based on the Bulma CSS framework."}

+ + +

{"This is the content of the first accordion."}

+
+ +

{"This is the content of the second accordion."}

+
+
+ + + + + +
+ + + + + {calendar_departure_date} + + + + + + + + + + + + + + + + + + + + + + + + + @@ -97,3 +193,86 @@ fn main() { yew::Renderer::::new().render(); } + +use ybc::ModalCloserContext; +use ybc::ModalCloserProvider; + +#[component] +pub fn MyModal1() -> Html { + let msg_ctx = use_context::().unwrap(); + let onclick = { Callback::from(move |e: MouseEvent| msg_ctx.dispatch("id0-close".to_string().parse().unwrap())) }; + let on_click_cb = Callback::from(move |e: AttrValue| { + gloo_console::log!("Button clicked!"); + }); + html! { + + {"Open Modal"} + + }} + // on_clicked={on_click_cb} + body={ + html!{ + +

{"This is the body of the modal."}

+
+ } + } + footer={html!( + <> + + {"Save changes"} + + + {"Close"} + + + )} + /> + } +} + +#[component(MyModal2)] +pub fn my_modal2() -> Html { + let msg_ctx = use_context::().unwrap(); + let onclick = { Callback::from(move |e: MouseEvent| msg_ctx.dispatch("id2-close".to_string().parse().unwrap())) }; + let msg_ctx2 = use_context::().unwrap(); + let onsave = { Callback::from(move |e: MouseEvent| msg_ctx2.dispatch("id2-close".to_string().parse().unwrap())) }; + let on_click_cb = Callback::from(move |e: AttrValue| { + gloo_console::log!("Button clicked!"); + }); + html! { + + {"Open Modal"} + + }} + // on_clicked={on_click_cb} + body={ + html!{ + +

{"This is the body of the modal2."}

+
+ } + } + footer={html!( + <> + + {"Save changes"} + + + {"Close"} + + + )} + /> + } +} diff --git a/rustfmt.toml b/rustfmt.toml index 008e901..5f72c59 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,27 +1,12 @@ -unstable_features = true -edition = "2021" +edition = "2024" -comment_width = 100 -fn_args_layout = "Compressed" +# Keep formatting width preferences max_width = 150 use_small_heuristics = "Default" -use_try_shorthand = true - -# pre-unstable -chain_width = 75 -single_line_if_else_max_width = 75 -space_around_attr_eq = false -struct_lit_width = 50 -# unstable -condense_wildcard_suffixes = true -format_code_in_doc_comments = true -format_strings = true -match_block_trailing_comma = false -normalize_comments = true -normalize_doc_attributes = true -reorder_impl_items = true -struct_lit_single_line = true -trailing_comma = "Vertical" +# Stable, safe rewrites +use_try_shorthand = true use_field_init_shorthand = true -wrap_comments = true + +# Replace deprecated option +fn_params_layout = "Compressed" diff --git a/src/columns/mod.rs b/src/columns/mod.rs index f038d0c..b96f9ff 100644 --- a/src/columns/mod.rs +++ b/src/columns/mod.rs @@ -20,7 +20,7 @@ pub struct ColumnsProps { /// The container for a set of responsive columns. /// /// [https://bulma.io/documentation/columns/](https://bulma.io/documentation/columns/) -#[function_component(Columns)] +#[component(Columns)] pub fn columns(props: &ColumnsProps) -> Html { let class = classes!( "columns", @@ -54,7 +54,7 @@ pub struct ColumnProps { /// This component has a very large number of valid class combinations which users may want. /// Modelling all of these is particularly for this component, so for now you are encouraged to /// add classes to this Component manually via the `classes` prop. -#[function_component(Column)] +#[component(Column)] pub fn column(props: &ColumnProps) -> Html { html! {
diff --git a/src/common.rs b/src/common.rs index 6ea71bb..ea1b4fd 100644 --- a/src/common.rs +++ b/src/common.rs @@ -4,27 +4,25 @@ use yew::html::IntoPropValue; /// Common alignment classes. #[derive(Clone, Debug, Display, PartialEq, Eq)] -#[display(fmt = "is-{}")] pub enum Alignment { - #[display(fmt = "left")] + #[display("is-left")] Left, - #[display(fmt = "centered")] + #[display("is-centered")] Centered, - #[display(fmt = "right")] + #[display("is-right")] Right, } /// Common size classes. #[derive(Clone, Debug, Display, PartialEq, Eq)] -#[display(fmt = "is-{}")] pub enum Size { - #[display(fmt = "small")] + #[display("is-small")] Small, - #[display(fmt = "normal")] + #[display("is-normal")] Normal, - #[display(fmt = "medium")] + #[display("is-medium")] Medium, - #[display(fmt = "large")] + #[display("is-large")] Large, } diff --git a/src/components/accordion.rs b/src/components/accordion.rs new file mode 100644 index 0000000..b8c66d9 --- /dev/null +++ b/src/components/accordion.rs @@ -0,0 +1,179 @@ +//! Accordion component: a Yew wrapper around the bulma-accordion plugin. +//! +//! Required static assets +//! - Add the bulma-accordion CSS into your HTML : +//! +//! +//! - Add the bulma-accordion JS so `bulmaAccordion` is available on window. Place this before your wasm bootstrap script +//! (or ensure it loads before your Yew app mounts): +//! +//! +//! How to configure index.html +//! - Minimal example (place CSS in , script before the wasm init script): +//! ```html +//! +//! +//! +//! +//! +//! +//! +//! +//! +//!
+//! +//! +//! +//! +//! +//! +//! +//! +//! ``` +//! +//! Notes and alternatives +//! - If you use a bundler (webpack, vite, etc.) you can install bulma-accordion from npm and import it in your JS entry: +//! npm install bulma-accordion +//! // in your entry file +//! import 'bulma-accordion/dist/css/bulma-accordion.min.css'; +//! import 'bulma-accordion/dist/js/bulma-accordion.min.js'; +//! Ensure the import runs before the Yew bootstrap so `bulmaAccordion` is available globally (or adapt the setup to pass the module). +//! +//! - The important requirement: bulmaAccordion must be defined on window when setup_accordion is called in rendered(). + +use std::rc::Rc; +use wasm_bindgen::JsValue; +use wasm_bindgen::prelude::wasm_bindgen; +use web_sys::Element; +use yew::prelude::*; + +#[component(AccordionItem)] +pub fn accordion_item(props: &AccordionItemProps) -> Html { + let accordion_classes = if props.open { "accordion is-active" } else { "accordion" }; + html! { +
+
+

{props.title.to_string()}

+
+
+
+ {props.children.clone()} +
+
+
+ } +} + +#[derive(Clone, Debug, PartialEq, Properties)] +pub struct AccordionsProps { + pub children: ChildrenWithProps, + pub id: Rc, +} + +pub struct Accordions { + props: AccordionsProps, +} + +#[derive(Properties, Clone, PartialEq)] +pub struct AccordionItemProps { + pub title: Rc, + pub children: Children, + #[prop_or_default] + pub open: bool, + #[prop_or_else(Callback::noop)] + pub on_toggle: Callback, + #[prop_or("".into())] + pub id: Rc, +} + +impl Component for Accordions { + type Message = (); + type Properties = AccordionsProps; + + fn create(ctx: &Context) -> Self { + Self { props: ctx.props().clone() } + } + + fn update(&mut self, ctx: &Context, _msg: Self::Message) -> bool { + self.props = ctx.props().clone(); + true + } + + fn view(&self, ctx: &Context) -> Html { + html! { +
+ {for ctx.props().children.iter().map(|child| { + html! {child.clone()} + })} +
+ } + } + + fn rendered(&mut self, ctx: &Context, first_render: bool) { + if first_render { + let window = web_sys::window().expect("no global `window` exists"); + let document = window.document().expect("should have a document on window"); + + let element = document + .get_element_by_id(ctx.props().id.to_string().as_str()) + .expect(format!("should have #{} on the page", ctx.props().id).as_str()); + + setup_accordion(&element); + } + } + + fn destroy(&mut self, ctx: &Context) { + detach_accordion(&JsValue::from_str(&ctx.props().id)); + } +} + +#[wasm_bindgen(inline_js = r#" +let accordionInstances = null; +export function setup_accordion(element) { + // console.log('Setting up accordion ID:' + element.id); + if (accordionInstances === null) { + accordionInstances = bulmaAccordion.attach('#' + element.id); + return; + } + + // Check if the accordion is already attached + for (let i = 0; i < accordionInstances.length; i++) { + if (accordionInstances[i].element && accordionInstances[i].element.id === element.id) { + // console.log('Accordion already attached to #id=' + element.id); + return; + } + } + + // If not attached, attach it + let newAccordion = bulmaAccordion.attach('#' + element.id); + accordionInstances.push(newAccordion); + // console.log('Accordion successfully attached to #id=' + element.id); + +} + +export function detach_accordion(id) { + for (let i = 0; i < accordionInstances.length; i++) { + if (accordionInstances[i] && accordionInstances[i].element && accordionInstances[i].element.id === id) { + // console.log('Detaching accordion #id='+id+'!'); + accordionInstances[i].destroy(); + accordionInstances.splice(i, 1); + // console.log(accordionInstances); // Log the accordionInstances array + break; + } + } + + if (accordionInstances.length === 0) { + accordionInstances = null; + // console.log('Detached accordion from all!'); + } +} + + +"#)] +extern "C" { + fn setup_accordion(element: &Element); + fn detach_accordion(id: &JsValue); +} diff --git a/src/components/autocomplete.rs b/src/components/autocomplete.rs new file mode 100644 index 0000000..7f4fd47 --- /dev/null +++ b/src/components/autocomplete.rs @@ -0,0 +1,303 @@ +//! AutoComplete component: a Yew wrapper around the Bulma Tags Input plugin. +//! +//! Required static assets +//! - Add the Bulma TagsInput CSS into your HTML : +//! +//! +//! - Add the Bulma TagsInput JS so `BulmaTagsInput` is available on window. Place this before your wasm bootstrap script +//! (or ensure it loads before your Yew app mounts): +//! +//! +//! How to configure index.html +//! - Minimal example (place CSS in , script before the wasm init script): +//! ```html +//! +//! +//! +//! +//! +//! +//! +//! +//! +//!
+//! +//! +//! +//! +//! +//! +//! +//! ``` +//! +//! Notes and alternatives +//! - If you use a bundler (webpack, vite, etc.) you can install bulma-tagsinput from npm and import it in your JS entry: +//! npm install @creativebulma/bulma-tagsinput +//! // in your entry file +//! import '@creativebulma/bulma-tagsinput/dist/css/bulma-tagsinput.min.css'; +//! import '@creativebulma/bulma-tagsinput/dist/js/bulma-tagsinput.min.js'; +//! Ensure the import runs before the Yew bootstrap so `BulmaTagsInput` is available globally (or adapt the setup to pass the module). +//! +//! - The important requirement: BulmaTagsInput must be defined on window when setup_static_autocomplete / setup_dynamic_autocomplete are called in rendered(). +//! + +use std::rc::Rc; +use wasm_bindgen::JsCast; +use wasm_bindgen::JsValue; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::prelude::wasm_bindgen; +use web_sys::js_sys::{JSON, Reflect}; +use web_sys::{Element, js_sys}; +use yew::prelude::*; + +pub struct AutoComplete { + id: Rc, +} + +#[derive(Clone, PartialEq, Properties)] +pub struct AutoCompleteProps { + #[prop_or("".to_string().into())] + pub id: Rc, + #[prop_or(10)] + pub max_items: u32, + #[prop_or_default] + pub items: Vec, + pub on_update: Callback, + pub on_remove: Callback, + #[prop_or("".to_string().into())] + pub current_selector: Rc, + #[prop_or("Choose Tags".to_string().into())] + pub placeholder: Rc, + #[prop_or(classes ! ("".to_string()))] + pub classes: Classes, + #[prop_or(true)] + pub case_sensitive: bool, + #[prop_or("".to_string().into())] + pub data_item_text: Rc, + #[prop_or("".to_string().into())] + pub data_item_value: Rc, + #[prop_or("".to_string().into())] + pub url_for_fetch: Rc, + #[prop_or("".to_string().into())] + pub auth_header: Rc, +} + +pub enum Msg { + Added(String), + Removed(String), +} + +impl Component for AutoComplete { + type Message = Msg; + type Properties = AutoCompleteProps; + fn create(ctx: &Context) -> Self { + Self { id: ctx.props().id.clone() } + } + + fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { + match msg { + Msg::Added(tag) => { + ctx.props().on_update.emit(tag); + // gloo_console::log!("Added: {}", tag.as_str()); + } + Msg::Removed(tag) => { + ctx.props().on_remove.emit(tag); + // gloo_console::log!("Removed: {}", tag.as_str()); + } + } + true + } + + fn view(&self, ctx: &Context) -> Html { + let current_selector = ctx.props().current_selector.to_string(); + let items = ctx + .props() + .items + .iter() + .map(|item| { + if item == current_selector.as_str() { + html! { + + } + } else { + html! { + + } + } + }) + .collect::(); + if ctx.props().items.len() > 0 && ctx.props().data_item_text.len() == 0 && ctx.props().data_item_value.len() == 0 { + html! { +
+ +
+ } + } else if ctx.props().data_item_text.len() > 0 && ctx.props().data_item_value.len() > 0 { + let has_value = current_selector.len() > 0; + let value = format!("{{\"{}\":\"{}\"}}", ctx.props().data_item_value, current_selector); + html! { + + } + } else { + html! { + + } + } + } + + fn rendered(&mut self, ctx: &Context, first_render: bool) { + if first_render { + let _max_items = ctx.props().max_items; + let _case_sensitive = ctx.props().case_sensitive; + let _url_for_fetch = ctx.props().url_for_fetch.clone(); + let _auth_header = ctx.props().auth_header.clone(); + let window = web_sys::window().expect("no global `window` exists"); + let document = window.document().expect("should have a document on window"); + + let element = document + .get_element_by_id(&*self.id) + .expect(format!("should have #{} on the page", self.id).as_str()); + + // Clone the link from the context + let link = ctx.link().clone(); + + // Move the cloned link into the closure + let callback = Closure::wrap(Box::new(move |tag: JsValue| { + // gloo_console::log!("Value changed: {}", tag.clone()); + let command: js_sys::Object = JSON::parse(tag.as_string().unwrap().as_str()).unwrap().dyn_into().unwrap(); + let op = Reflect::get(&command, &JsValue::from_str("op")).unwrap(); + let value = Reflect::get(&command, &JsValue::from_str("value")).unwrap(); + if op.as_string().unwrap() == "add" { + link.send_message(Msg::Added(value.as_string().unwrap())); + } else { + link.send_message(Msg::Removed(value.as_string().unwrap())); + } + }) as Box); + if _url_for_fetch.len() == 0 { + setup_static_autocomplete(&element, callback.as_ref(), &JsValue::from(_max_items), &JsValue::from(_case_sensitive)); + } else { + setup_dynamic_autocomplete( + &element, + callback.as_ref(), + &JsValue::from(_max_items), + &JsValue::from(_url_for_fetch.to_string()), + &JsValue::from(_auth_header.to_string()), + &JsValue::from(_case_sensitive), + &JsValue::from(ctx.props().data_item_value.to_string()), + &JsValue::from(ctx.props().current_selector.to_string()), + ); + } + callback.forget(); + } + } + + fn destroy(&mut self, _ctx: &Context) { + detach_autocomplete(&JsValue::from(self.id.as_ptr())); + } +} + +#[wasm_bindgen(inline_js = r#" +let init = new Map(); +export function setup_dynamic_autocomplete(element, callback, max_tags, url_for_fetch, auth_header, case_sensitive, data_item_value, initial_value) { + // Attach Bulma autocomplete here + // console.log('Setting up dynamic autocomplete ID:' + element.id + ' fetch:' + url_for_fetch + ' auth:' + auth_header + ' case:' + case_sensitive + ' max:' + max_tags); + if (!init.has(element.id)) { + // console.log('Setting up dynamic autocomplete ID:' + element.id); + let autocompleteInstance = BulmaTagsInput.attach( element, { + maxTags: max_tags, + caseSensitive: case_sensitive, + source: async function(value) { + // console.log('Fetching data for:'+value); + return await fetch(url_for_fetch + value) + .then(function(response) { + if (response.status !== 200) { + throw new Error('Failed to fetch data'); + } + return response.json(); + });}, + }); + let autocomplete = autocompleteInstance[0]; + // console.log('Attached autocomplete:'+element.id + ' ' + autocomplete); + autocomplete.on('after.add', function(tag) { + // console.log(tag); + callback('{"op":"add","value":"'+tag.item[data_item_value]+'"}'); + }); + autocomplete.on('after.remove', function(tag) { + // console.log(tag); + callback('{"op":"remove","value":"'+tag[data_item_value]+'"}'); + }); + if (initial_value.length > 0) { + autocomplete.add('{"'+data_item_value+'":"'+initial_value+'"}'); + } + + init.set(element.id, autocomplete); + } +} + +export function setup_static_autocomplete(element, callback, max_tags, case_sensitive) { + // Attach Bulma autocomplete here + // console.log('Setting up static autocomplete ID:' + element.id + ' case:' + case_sensitive + ' max:' + max_tags); + if (!init.has(element.id)) { + let autocompleteInstance = BulmaTagsInput.attach( element, { + maxTags: max_tags, + caseSensitive: case_sensitive, + }); + let autocomplete = autocompleteInstance[0]; + // console.log('Attached autocomplete:'+element.id + ' ' + autocomplete); + autocomplete.on('after.add', function(tag) { + // console.log(tag); + if (tag.item && tag.item.value) { + callback('{"op":"add","value":"'+tag.item.value+'"}'); + } else if (tag.value) { + callback('{"op":"add","value":"'+tag.value+'"}'); + } else { + callback('{"op":"add","value":"'+tag.item+'"}'); + } + }); + autocomplete.on('after.remove', function(tag) { + // console.log(tag); + if (tag.item && tag.item.value) { + callback('{"op":"remove","value":"'+tag.item.value+'"}'); + } else if (tag.value) { + callback('{"op":"remove","value":"'+tag.value+'"}'); + } else { + callback('{"op":"remove","value":"'+tag+'"}'); + } + }); + + init.set(element.id, autocomplete); + + } +} + +export function detach_autocomplete(id) { + init.delete(id); + // console.log('Detached autocomplete:'+id); +} + +"#)] +extern "C" { + fn setup_dynamic_autocomplete( + element: &Element, callback: &JsValue, max_tags: &JsValue, url_to_fetch: &JsValue, auth_header: &JsValue, case_sensitive: &JsValue, + data_item_value: &JsValue, initial_value: &JsValue, + ); + fn setup_static_autocomplete(element: &Element, callback: &JsValue, max_tags: &JsValue, case_sensitive: &JsValue); + fn detach_autocomplete(id: &JsValue); +} diff --git a/src/components/breadcrumb.rs b/src/components/breadcrumb.rs index 689eea2..e9cb1fc 100644 --- a/src/components/breadcrumb.rs +++ b/src/components/breadcrumb.rs @@ -24,7 +24,7 @@ pub struct BreadcrumbProps { /// A simple breadcrumb component to improve your navigation experience. /// /// [https://bulma.io/documentation/components/breadcrumb/](https://bulma.io/documentation/components/breadcrumb/) -#[function_component(Breadcrumb)] +#[component(Breadcrumb)] pub fn breadcrumb(props: &BreadcrumbProps) -> Html { let class = classes!( "breadcrumb", @@ -46,13 +46,12 @@ pub fn breadcrumb(props: &BreadcrumbProps) -> Html { /// /// https://bulma.io/documentation/components/breadcrumb/#sizes #[derive(Clone, Debug, Display, PartialEq, Eq)] -#[display(fmt = "are-{}")] pub enum BreadcrumbSize { - #[display(fmt = "small")] + #[display("are-small")] Small, - #[display(fmt = "medium")] + #[display("are-medium")] Medium, - #[display(fmt = "large")] + #[display("are-large")] Large, } @@ -60,14 +59,13 @@ pub enum BreadcrumbSize { /// /// https://bulma.io/documentation/components/breadcrumb/#alternative-separators #[derive(Clone, Debug, Display, PartialEq, Eq)] -#[display(fmt = "has-{}-separator")] pub enum BreadcrumbSeparator { - #[display(fmt = "arrow")] + #[display("has-arrow-separator")] Arrow, - #[display(fmt = "bullet")] + #[display("has-bullet-separator")] Bullet, - #[display(fmt = "dot")] + #[display("has-dot-separator")] Dot, - #[display(fmt = "succeeds")] + #[display("has-succeeds-separator")] Succeeds, } diff --git a/src/components/calendar.rs b/src/components/calendar.rs new file mode 100644 index 0000000..3767582 --- /dev/null +++ b/src/components/calendar.rs @@ -0,0 +1,270 @@ +//! Calendar component: a thin Yew wrapper around the bulma‑calendar JS date/time picker. +//! +//! Summary +//! - Enhances a plain `` with bulmaCalendar for date and time selection. +//! - Emits changes through a Yew `Callback` whenever the user selects, validates, or clears. +//! - Requires bulmaCalendar JS and CSS to be loaded globally (available as `bulmaCalendar`). +//! +//! Value format +//! - The emitted string follows the configured `date_format` and `time_format` patterns understood by bulmaCalendar. +//! - Clearing the picker emits an empty string. +//! +//! Usage +//! ```rust +//! use yew::prelude::*; +//! use ybc::components::calendar::Calendar; +//! +//! #[component(Form)] +//! fn form() -> Html { +//! let date = use_state(|| Option::::None); +//! let on_date_changed = { +//! let date = date.clone(); +//! Callback::from(move |d: String| { +//! date.set(if d.is_empty() { None } else { Some(d) }); +//! }) +//! }; +//! +//! html! { +//! +//! } +//! } +//! ``` +//! +//! Required static assets +//! - Add the bulma‑calendar CSS into your HTML : +//! +//! +//! - Add the bulma‑calendar JS so `bulmaCalendar` is available on window. Place this before your wasm bootstrap script +//! (or ensure it loads before your Yew app mounts): +//! +//! +//! How to configure index.html +//! - Minimal example (place CSS in , script before the wasm init script): +//! ```html +//! +//! +//! +//! +//! +//! +//! +//! +//! +//!
+//! +//! +//! +//! +//! +//! +//! +//! +//! ``` +//! +//! Notes and alternatives +//! - If you use a bundler (webpack, vite, etc.) you can install bulma-calendar from npm and import it in your JS entry: +//! npm install bulma-calendar +//! // in your entry file +//! import 'bulma-calendar/dist/css/bulma-calendar.min.css'; +//! import bulmaCalendar from 'bulma-calendar'; +//! Ensure the import runs before the Yew bootstrap so `bulmaCalendar` is available globally (or adapt the setup to pass the module). +//! +//! - The important requirement: bulmaCalendar must be defined on window when setup_date_picker is called in the component's rendered() hook. +//! +//! - If you prefer loading the script asynchronously, make sure to delay Yew app start until bulmaCalendar is available (e.g. listen for script load). +//! +//! Implementation notes +//! - The `id` must be unique in the DOM; it is used to attach and detach the JS calendar instance. +//! - The underlying `` uses `type="datetime"` for the JS widget; the plugin drives rendering and value handling. +//! +//! ... + +use wasm_bindgen::JsValue; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::prelude::wasm_bindgen; +use web_sys::Element; +use yew::prelude::*; +use yew::{Callback, Component, Context, Html}; + +/// Internal component state for the calendar widget. +pub struct Calendar { + /// Local format placeholder (currently unused by the widget; formats are passed to JS). + format: String, + /// Current date/time value as a string matching the widget's configured formats. + date: Option, + /// DOM id of the `` used to attach bulmaCalendar. + id: String, +} + +/// Properties for the `Calendar` component. +/// +/// - `id`: required, unique DOM id for the input (used to attach/detach the JS widget). +/// - `date_format`: optional; bulmaCalendar date pattern (default: `yyyy-MM-dd`). +/// - `time_format`: optional; bulmaCalendar time pattern (default: `HH:mm`). +/// - `date`: optional initial/current value; will be pushed into the widget on first render. +/// - `on_date_changed`: invoked on select/validate/clear with the current value (empty on clear). +/// - `class`: optional extra CSS classes appended to the input, e.g., `vec!["is-small".into()]`. +#[derive(Clone, PartialEq, Properties)] +pub struct CalendarProps { + /// Unique DOM id of the input element. + pub id: String, + /// bulmaCalendar date pattern, e.g., `yyyy-MM-dd`. Falls back to `yyyy-MM-dd` if empty. + #[prop_or_default] + pub date_format: String, + /// bulmaCalendar time pattern, e.g., `HH:mm`. Falls back to `HH:mm` if empty. + #[prop_or_default] + pub time_format: String, + /// Optional initial/current value for the calendar. + pub date: Option, + /// Callback fired whenever the date/time value changes. Receives empty string on clear. + pub on_date_changed: Callback, + /// Extra CSS classes for the input. + pub class: Vec, +} + +/// Internal messages for the component update loop. +pub enum Msg { + /// User changed the date/time value through the widget. + DateChanged(String), +} + +impl Component for Calendar { + type Message = Msg; + type Properties = CalendarProps; + + /// Initializes the component with provided props and default format strings. + fn create(ctx: &Context) -> Self { + Calendar { + format: "%Y-%m-%d %H:%M".to_string(), + date: ctx.props().date.clone(), + id: ctx.props().id.clone(), + } + } + + /// Handles internal messages by updating local state and notifying the parent via callback. + fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { + match msg { + Msg::DateChanged(date) => { + self.date = Some(date.clone()); + ctx.props().on_date_changed.emit(date); + true + } + } + } + + /// Renders the backing input element. The bulmaCalendar widget attaches to this element. + fn view(&self, ctx: &Context) -> Html { + let _value = self.date.clone(); + let _id = self.id.clone(); + let classes = classes!("input", ctx.props().class.clone()); + html! { + + } + } + + /// After first render, attaches the bulmaCalendar instance and wires event callbacks. + fn rendered(&mut self, ctx: &Context, first_render: bool) { + if first_render { + let window = web_sys::window().expect("no global `window` exists"); + let document = window.document().expect("should have a document on window"); + + let element = document + .get_element_by_id(self.id.as_str()) + .expect(format!("should have #{} on the page", self.id.as_str()).as_str()); + + // Clone the link for use inside the JS callback closure. + let link = ctx.link().clone(); + + // JS -> Rust bridge: receive the string value and forward as a Yew message. + let callback = Closure::wrap(Box::new(move |date: JsValue| { + let date_str = date.as_string().unwrap_or_default(); + link.send_message(Msg::DateChanged(date_str)); + }) as Box); + + // Resolve formats, falling back to sensible defaults if props are empty. + let _date_format = if ctx.props().date_format.trim().is_empty() { + "yyyy-MM-dd".to_string() + } else { + ctx.props().date_format.trim().to_string() + }; + + let _time_format = if ctx.props().time_format.trim().is_empty() { + "HH:mm".to_string() + } else { + ctx.props().time_format.trim().to_string() + }; + + // Attach the JS widget and seed its initial value. + setup_date_picker( + &element, + callback.as_ref(), + &JsValue::from(self.date.as_ref().unwrap_or(&"".to_string())), + &JsValue::from(_date_format), + &JsValue::from(_time_format), + ); + + // Intentionally leak the closure to keep it alive for the widget's lifetime. + callback.forget(); + } + } + + /// Before unmount, detach JS state keyed by the element id. + fn destroy(&mut self, _ctx: &Context) { + detach_date_picker(&JsValue::from(self.id.as_str())); + } +} + +/// JS bridge that attaches bulmaCalendar to the provided element and wires a change callback. +/// +/// Safety/expectations: +/// - `element` must be an `` present in the DOM with a stable `id`. +/// - `callback` must remain alive for as long as the widget can invoke it (we call `forget()`). +/// - bulmaCalendar must be globally available. +#[wasm_bindgen(inline_js = r#" +let init = new Map(); +export function setup_date_picker(element, callback, initial_date, date_format, time_format) { + if (!init.has(element.id)) { + let calendarInstances = bulmaCalendar.attach(element, { + type: 'datetime', + color: 'info', + lang: 'en', + dateFormat: date_format, + timeFormat: time_format, + }); + init.set(element.id, calendarInstances[0]); + let calendarInstance = calendarInstances[0]; + calendarInstance.on('select', function(datepicker) { + callback(datepicker.data.value()); + }); + calendarInstance.on('clear', function(_datepicker) { + callback(''); + }); + calendarInstance.on('validate', function(datepicker) { + callback(datepicker.data.value()); + }); + } + init.get(element.id).value(initial_date); +} + +export function detach_date_picker(id) { + init.delete(id); +} +"#)] +extern "C" { + /// Attach bulmaCalendar to `element`, register `callback`, seed with `initial_date`, + /// and apply `date_format`/`time_format`. + fn setup_date_picker(element: &Element, callback: &JsValue, initial_date: &JsValue, date_format: &JsValue, time_format: &JsValue); + + /// Remove the stored calendar instance keyed by the given `id`. + fn detach_date_picker(id: &JsValue); +} diff --git a/src/components/card.rs b/src/components/card.rs index 34caaf0..9f35652 100644 --- a/src/components/card.rs +++ b/src/components/card.rs @@ -11,7 +11,7 @@ pub struct CardProps { /// An all-around flexible and composable component; this is the card container. /// /// [https://bulma.io/documentation/components/card/](https://bulma.io/documentation/components/card/) -#[function_component(Card)] +#[component(Card)] pub fn card(props: &CardProps) -> Html { html! {
@@ -34,7 +34,7 @@ pub struct CardHeaderProps { /// A container for card header content; rendered as a horizontal bar with a shadow. /// /// [https://bulma.io/documentation/components/card/](https://bulma.io/documentation/components/card/) -#[function_component(CardHeader)] +#[component(CardHeader)] pub fn card_header(props: &CardHeaderProps) -> Html { html! {
@@ -57,7 +57,7 @@ pub struct CardImageProps { /// A fullwidth container for a responsive image. /// /// [https://bulma.io/documentation/components/card/](https://bulma.io/documentation/components/card/) -#[function_component(CardImage)] +#[component(CardImage)] pub fn card_image(props: &CardImageProps) -> Html { html! {
@@ -80,7 +80,7 @@ pub struct CardContentProps { /// A container for any other content as the body of the card. /// /// [https://bulma.io/documentation/components/card/](https://bulma.io/documentation/components/card/) -#[function_component(CardContent)] +#[component(CardContent)] pub fn card_content(props: &CardContentProps) -> Html { html! {
@@ -103,7 +103,7 @@ pub struct CardFooterProps { /// A container for card footer content; rendered as a horizontal list of controls. /// /// [https://bulma.io/documentation/components/card/](https://bulma.io/documentation/components/card/) -#[function_component(CardFooter)] +#[component(CardFooter)] pub fn card_footer(props: &CardFooterProps) -> Html { html! {