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..bdd998b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,23 +1,24 @@ [package] name = "ybc" -version = "0.4.0" +version = "0.4.4" 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.1.1", default-features = false, features = ["display"] } +web-sys = { version = "0.3.85", features = ["Element", "Event", "File", "HtmlCollection", "HtmlDialogElement", "HtmlElement", "HtmlSelectElement", "MouseEvent"] } +yew = { version = "0.22.0", features = ["csr"] } +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 +27,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..ab49940 100644 --- a/examples/basic/index.html +++ b/examples/basic/index.html @@ -1,19 +1,28 @@ - + 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..43a560d 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 +191,86 @@ fn main() { yew::Renderer::::new().render(); } + +use ybc::ModalControllerContext; +use ybc::ModalControllerProvider; + +#[component] +pub fn MyModal1() -> Html { + let controller = use_context::().unwrap(); + let onclick = { + let controller = controller.clone(); + Callback::from(move |_| controller.close("id0")) + }; + html! { + + {"Open Modal"} + + }} + body={ + html!{ + +

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

+
+ } + } + footer={html!( + <> + + {"Save changes"} + + + {"Close"} + + + )} + /> + } +} + +#[component(MyModal2)] +pub fn my_modal2() -> Html { + let controller = use_context::().unwrap(); + let onclick = { + let controller = controller.clone(); + Callback::from(move |_| controller.close("id2")) + }; + let onsave = { + let controller = controller.clone(); + Callback::from(move |_| controller.close("id2")) + }; + html! { + + {"Open Modal"} + + }} + 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..af45790 --- /dev/null +++ b/src/components/accordion.rs @@ -0,0 +1,272 @@ +//! 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 std::sync::atomic::{AtomicUsize, Ordering}; +use wasm_bindgen::JsValue; +use wasm_bindgen::prelude::wasm_bindgen; +use web_sys::Element; +use yew::events::{KeyboardEvent, MouseEvent}; +use yew::prelude::*; + +static ACCORDION_ITEM_AUTO_ID: AtomicUsize = AtomicUsize::new(1); + +fn next_accordion_item_id() -> String { + format!("accordion-item-{}", ACCORDION_ITEM_AUTO_ID.fetch_add(1, Ordering::Relaxed)) +} + +#[component(AccordionItem)] +pub fn accordion_item(props: &AccordionItemProps) -> Html { + let internal_open = use_state(|| props.open); + let is_controlled = props.controlled_open.is_some() && props.set_open.is_some(); + let is_open = props.controlled_open.unwrap_or(*internal_open); + + let set_local_open = { + let internal_open = internal_open.clone(); + let set_open = props.set_open.clone(); + Callback::from(move |value: bool| { + if is_controlled { + if let Some(set_open) = set_open.as_ref() { + set_open.emit(value); + } + } else { + internal_open.set(value); + } + }) + }; + + { + let on_open = props.on_open.clone(); + let on_close = props.on_close.clone(); + let prev_open = use_mut_ref(move || is_open); + use_effect_with(is_open, move |is_open| { + let mut prev = prev_open.borrow_mut(); + if *prev != *is_open { + if *is_open { + on_open.emit(()); + } else { + on_close.emit(()); + } + *prev = *is_open; + } + || {} + }); + } + + let auto_id = use_state(|| Rc::::from(next_accordion_item_id())); + let item_id = if props.id.is_empty() { (*auto_id).clone() } else { props.id.clone() }; + let header_id = AttrValue::from(format!("{}-header", item_id)); + let panel_id = AttrValue::from(format!("{}-panel", item_id)); + let accordion_classes = if is_open { "accordion is-active" } else { "accordion" }; + + let on_click = { + let set_local_open = set_local_open.clone(); + let on_toggle = props.on_toggle.clone(); + let is_open = is_open; + Callback::from(move |event: MouseEvent| { + set_local_open.emit(!is_open); + on_toggle.emit(event); + }) + }; + + let on_keydown = { + let set_local_open = set_local_open.clone(); + let is_open = is_open; + Callback::from(move |event: KeyboardEvent| { + let key = event.key(); + if key == "Enter" || key == " " { + event.prevent_default(); + set_local_open.emit(!is_open); + } + }) + }; + + 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, + /// Initial open state for uncontrolled mode. + #[prop_or_default] + pub open: bool, + /// Controlled open state. + #[prop_or_default] + pub controlled_open: Option, + /// Controlled open setter. + #[prop_or_default] + pub set_open: Option>, + /// Called when the item opens. + #[prop_or_default] + pub on_open: Callback<()>, + /// Called when the item closes. + #[prop_or_default] + pub on_close: Callback<()>, + #[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()) + .unwrap_or_else(|| panic!("should have #{} on the page", ctx.props().id)); + + 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..cd41c98 --- /dev/null +++ b/src/components/autocomplete.rs @@ -0,0 +1,318 @@ +//! 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.is_empty() && ctx.props().data_item_text.is_empty() && ctx.props().data_item_value.is_empty() { + html! { +
+ +
+ } + } else if !ctx.props().data_item_text.is_empty() && !ctx.props().data_item_value.is_empty() { + let has_value = !current_selector.is_empty(); + 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) + .unwrap_or_else(|| panic!("should have #{} on the page", self.id)); + + // 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| { + let Some(raw) = tag.as_string() else { + return; + }; + let Ok(parsed) = JSON::parse(raw.as_str()) else { + return; + }; + let Ok(command) = parsed.dyn_into::() else { + return; + }; + let Ok(op) = Reflect::get(&command, &JsValue::from_str("op")) else { + return; + }; + let Ok(value) = Reflect::get(&command, &JsValue::from_str("value")) else { + return; + }; + let Some(value) = value.as_string() else { + return; + }; + + if op.as_string().as_deref() == Some("add") { + link.send_message(Msg::Added(value)); + } else { + link.send_message(Msg::Removed(value)); + } + }) as Box); + if _url_for_fetch.is_empty() { + 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_str(self.id.as_ref())); + } +} + +#[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..e5a3b19 --- /dev/null +++ b/src/components/calendar.rs @@ -0,0 +1,353 @@ +/*! +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 Rust 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. + +Programmatic control +- To update the picker value from the outside, update the `date` prop. +- To clear the picker from the outside, set `date` to a single space `" "`. + +Required static assets +- CSS (add in ``): + https://cdn.jsdelivr.net/npm/bulma-calendar@7.1.1/dist/css/bulma-calendar.min.css +- JS (load before WASM bootstrap so `bulmaCalendar` exists): + https://cdn.jsdelivr.net/npm/bulma-calendar@7.1.1/dist/js/bulma-calendar.min.js +*/ + +use yew::prelude::*; + +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::JsCast; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::JsValue; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::closure::Closure; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::prelude::wasm_bindgen; +#[cfg(target_arch = "wasm32")] +use web_sys::Element; + +#[cfg(target_arch = "wasm32")] +type CalendarClosure = Closure; +#[cfg(not(target_arch = "wasm32"))] +type CalendarClosure = (); + +/// Optional test attribute rendered on the input. +/// +/// Supported keys: +/// - `data-testid` +/// - `data-cy` +#[derive(Clone, Debug, PartialEq)] +pub struct TestAttr { + pub key: AttrValue, + pub value: AttrValue, +} + +impl TestAttr { + pub fn test_id(value: impl Into) -> Self { + Self { + key: AttrValue::from("data-testid"), + value: value.into(), + } + } + + pub fn data_cy(value: impl Into) -> Self { + Self { + key: AttrValue::from("data-cy"), + value: value.into(), + } + } +} + +impl From for TestAttr +where + T: Into, +{ + fn from(value: T) -> Self { + Self::test_id(value) + } +} + +/// Properties for [`Calendar`]. +#[derive(Clone, PartialEq, Properties)] +pub struct CalendarProps { + /// Unique DOM id for the input (used to attach/detach the JS widget). + pub id: String, + + /// Date format understood by bulmaCalendar. Defaults to `yyyy-MM-dd` when empty. + #[prop_or_default] + pub date_format: AttrValue, + + /// Time format understood by bulmaCalendar. Defaults to `HH:mm` when empty. + #[prop_or_default] + pub time_format: AttrValue, + + /// Optional initial/current value to seed or update the widget. + #[prop_or_default] + pub date: Option, + + /// Callback invoked when the date/time changes; receives empty string on clear. + pub on_date_changed: Callback, + + /// Extra classes appended after Bulma `input`. + #[prop_or_default] + pub class: Vec, + + /// Optional test attribute on the input (`data-testid` or `data-cy`). + #[prop_or_default] + pub test_attr: Option, + + /// Picker type (`date`, `time`, `datetime`). + /// If empty, defaults to `datetime` when `time_format` is present, otherwise `date`. + #[prop_or_default] + pub calendar_type: AttrValue, +} + +/// A date/time input enhanced by bulma-calendar. +#[function_component(Calendar)] +pub fn calendar(props: &CalendarProps) -> Html { + let input_ref = use_node_ref(); + + let date_format_raw = props.date_format.trim().to_string(); + assert!( + date_format_raw.is_empty() || date_format_raw == "yyyy-MM-dd", + "Calendar date_format must be exactly 'yyyy-MM-dd' (lowercase yyyy-MM-dd). Got '{}'", + props.date_format + ); + + let date_format = if date_format_raw.is_empty() { + "yyyy-MM-dd".to_owned() + } else { + date_format_raw + }; + + let time_format_raw = props.time_format.trim().to_string(); + let time_format = if time_format_raw.is_empty() { + "HH:mm".to_owned() + } else { + time_format_raw.clone() + }; + + let calendar_type = { + let explicit = props.calendar_type.trim(); + if explicit.is_empty() { + if props.time_format.trim().is_empty() { + "date".to_owned() + } else { + "datetime".to_owned() + } + } else { + explicit.to_owned() + } + }; + + let initial_value = props.date.clone().unwrap_or_default(); + let class = classes!("input", props.class.clone()); + + let (data_testid, data_cy) = match props.test_attr.as_ref() { + Some(attr) if attr.key == "data-testid" => (Some(attr.value.clone()), None), + Some(attr) if attr.key == "data-cy" => (None, Some(attr.value.clone())), + _ => (None, None), + }; + + let input_type = if props.time_format.trim().is_empty() { + AttrValue::from("date") + } else { + AttrValue::from("datetime") + }; + + let callback_store = use_mut_ref(|| None::); + let on_date_changed_ref = use_mut_ref(|| props.on_date_changed.clone()); + *on_date_changed_ref.borrow_mut() = props.on_date_changed.clone(); + + { + let id = props.id.clone(); + let input_ref = input_ref.clone(); + let callback_store = callback_store.clone(); + let on_date_changed_ref = on_date_changed_ref.clone(); + let date_format = date_format.clone(); + let time_format = time_format.clone(); + let calendar_type = calendar_type.clone(); + let initial_value = initial_value.clone(); + + use_effect_with( + (id.clone(), date_format.clone(), time_format.clone(), calendar_type.clone()), + move |(id, date_format, time_format, calendar_type)| { + #[cfg(target_arch = "wasm32")] + { + if let Some(element) = input_ref.cast::() { + let on_date_changed_ref = on_date_changed_ref.clone(); + let callback = Closure::wrap(Box::new(move |date: JsValue| { + let s = date.as_string().unwrap_or_default(); + on_date_changed_ref.borrow().emit(s); + }) as Box); + + setup_date_picker( + &element, + callback.as_ref(), + &JsValue::from(initial_value.clone()), + &JsValue::from(date_format.clone()), + &JsValue::from(time_format.clone()), + &JsValue::from(calendar_type.clone()), + ); + + *callback_store.borrow_mut() = Some(callback); + } + + let callback_store = callback_store.clone(); + let id_for_cleanup = id.clone(); + return move || { + detach_date_picker(&JsValue::from(id_for_cleanup.as_str())); + callback_store.borrow_mut().take(); + }; + } + + #[cfg(not(target_arch = "wasm32"))] + { + let _ = ( + &input_ref, + &callback_store, + &on_date_changed_ref, + &initial_value, + id, + date_format, + time_format, + calendar_type, + ); + || {} + } + }, + ); + } + + { + let id = props.id.clone(); + let date = props.date.clone(); + use_effect_with((id, date), move |(id, date)| { + #[cfg(target_arch = "wasm32")] + { + match date.as_deref() { + Some(" ") | Some("") => { + clear_date(&JsValue::from(id.as_str())); + } + Some(v) => { + update_value(&JsValue::from(id.as_str()), &JsValue::from(v)); + } + None => {} + } + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (id, date); + } + + || {} + }); + } + + html! { + + } +} + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen(inline_js = r#" +let init = new Map(); + +export function setup_date_picker(element, callback, initial_date, date_format, time_format, picker_type) { + if (!element || !element.id) { + return; + } + + if (typeof bulmaCalendar === 'undefined' || typeof bulmaCalendar.attach !== 'function') { + console.warn('bulmaCalendar is not available on window. Calendar will remain a plain input.'); + return; + } + + if (!init.has(element.id)) { + const instances = bulmaCalendar.attach(element, { + type: picker_type || (String(time_format || '').trim() ? 'datetime' : 'date'), + color: 'info', + lang: 'en', + dateFormat: date_format, + timeFormat: time_format, + showTodayButton: false + }); + + if (!instances || !instances.length) { + return; + } + + const calendarInstance = instances[0]; + init.set(element.id, calendarInstance); + + calendarInstance.on('select', function(datepicker) { + callback(datepicker.data.value()); + }); + + calendarInstance.on('clear', function(_datepicker) { + callback(''); + }); + + calendarInstance.on('validate', function(datepicker) { + callback(datepicker.data.value()); + if (typeof calendarInstance.hide === 'function') { + calendarInstance.hide(); + } + }); + } + + if (init.has(element.id)) { + init.get(element.id).value(initial_date || ''); + } +} + +export function detach_date_picker(id) { + if (init.has(id)) { + const instance = init.get(id); + if (instance && typeof instance.destroy === 'function') { + instance.destroy(); + } + init.delete(id); + } +} + +export function clear_date(id) { + if (init.has(id)) { + init.get(id).clear(); + } +} + +export function update_value(id, value) { + if (init.has(id)) { + init.get(id).value(value || ''); + } +} +"#)] +#[allow(improper_ctypes, improper_ctypes_definitions)] +extern "C" { + fn setup_date_picker( + element: &Element, callback: &JsValue, initial_date: &JsValue, date_format: &JsValue, time_format: &JsValue, picker_type: &JsValue, + ); + + fn detach_date_picker(id: &JsValue); + + fn clear_date(id: &JsValue); + + fn update_value(id: &JsValue, value: &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! {