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..199942c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,23 +1,24 @@ [package] name = "ybc" -version = "0.4.0" +version = "0.4.5" 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.23.0", features = ["csr"] } +yew-router = { version = "0.20.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..3fe1b73 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 = { version = "0.23.0", features = ["csr"] } [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! {
diff --git a/src/components/dropdown.rs b/src/components/dropdown.rs index fe8ec5a..775afce 100644 --- a/src/components/dropdown.rs +++ b/src/components/dropdown.rs @@ -1,7 +1,16 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; + +use yew::events::{KeyboardEvent, MouseEvent}; use yew::prelude::*; use crate::elements::button::Button; +static DROPDOWN_AUTO_ID: AtomicUsize = AtomicUsize::new(1); + +fn next_dropdown_id() -> String { + format!("dropdown-{}", DROPDOWN_AUTO_ID.fetch_add(1, Ordering::Relaxed)) +} + #[derive(Clone, Debug, Properties, PartialEq)] pub struct DropdownProps { /// The content of the dropdown menu. @@ -20,9 +29,36 @@ pub struct DropdownProps { /// The content of the trigger button. #[prop_or_default] pub button_html: Html, + /// Optional id used as the root element id. + #[prop_or_default] + pub id: Option, + /// Controlled open state. + #[prop_or_default] + pub open: Option, + /// Controlled open state setter. + #[prop_or_default] + pub set_open: Option>, + /// Called after the menu opens. + #[prop_or_default] + pub on_open: Callback<()>, + /// Called after the menu closes. + #[prop_or_default] + pub on_close: Callback<()>, + /// Allows closing the menu with Escape. + #[prop_or(true)] + pub close_on_escape: bool, + /// Allows closing the menu by clicking outside. + #[prop_or(true)] + pub close_on_click_outside: bool, + /// Optional trigger label for assistive technologies. + #[prop_or_default] + pub button_aria_label: AttrValue, + /// Optional menu label for assistive technologies. + #[prop_or_default] + pub menu_aria_label: AttrValue, } -/// Dropdown actions. +/// Dropdown actions kept for backwards compatibility. pub enum DropdownMsg { Open, Close, @@ -31,58 +67,140 @@ pub enum DropdownMsg { /// An interactive dropdown menu for discoverable content. /// /// [https://bulma.io/documentation/components/dropdown/](https://bulma.io/documentation/components/dropdown/) -pub struct Dropdown { - is_menu_active: bool, -} +#[component(Dropdown)] +pub fn dropdown(props: &DropdownProps) -> Html { + let internal_open = use_state(|| false); + let is_controlled = props.open.is_some() && props.set_open.is_some(); + let is_open = props.open.unwrap_or(*internal_open); -impl Component for Dropdown { - type Message = DropdownMsg; - type Properties = DropdownProps; + 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); + } + }) + }; - fn create(_ctx: &Context) -> Self { - Self { is_menu_active: false } + { + 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; + } + || {} + }); } - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - if ctx.props().hoverable { - return false; - } - match msg { - DropdownMsg::Open => self.is_menu_active = true, - DropdownMsg::Close => self.is_menu_active = false, - } - true + let auto_id = use_state(|| AttrValue::from(next_dropdown_id())); + let root_id = props.id.clone().unwrap_or_else(|| (*auto_id).clone()); + let menu_id = AttrValue::from(format!("{}-menu", root_id)); + let button_aria_label = (!props.button_aria_label.is_empty()).then_some(props.button_aria_label.clone()); + let menu_aria_label = (!props.menu_aria_label.is_empty()).then_some(props.menu_aria_label.clone()); + + let on_trigger_click = { + let set_local_open = set_local_open.clone(); + let hoverable = props.hoverable; + let is_open = is_open; + Callback::from(move |event: MouseEvent| { + if hoverable { + return; + } + event.prevent_default(); + set_local_open.emit(!is_open); + }) + }; + + let on_trigger_keydown = { + let set_local_open = set_local_open.clone(); + let hoverable = props.hoverable; + let close_on_escape = props.close_on_escape; + Callback::from(move |event: KeyboardEvent| { + if hoverable { + return; + } + match event.key().as_str() { + "Enter" | " " | "ArrowDown" => { + event.prevent_default(); + set_local_open.emit(true); + } + "Escape" if close_on_escape => { + event.prevent_default(); + set_local_open.emit(false); + } + _ => {} + } + }) + }; + + let on_root_keydown = { + let set_local_open = set_local_open.clone(); + let close_on_escape = props.close_on_escape; + Callback::from(move |event: KeyboardEvent| { + if close_on_escape && event.key() == "Escape" { + event.prevent_default(); + set_local_open.emit(false); + } + }) + }; + + let on_overlay_click = { + let set_local_open = set_local_open.clone(); + Callback::from(move |_| set_local_open.emit(false)) + }; + + let mut class = classes!("dropdown", props.classes.clone()); + if props.hoverable { + class.push("is-hoverable"); + } else if is_open { + class.push("is-active"); } - fn view(&self, ctx: &Context) -> Html { - let mut class = Classes::from("dropdown"); - class.push(ctx.props().classes.clone()); - let opencb = if ctx.props().hoverable { - class.push("is-hoverable"); - Callback::noop() - } else { - ctx.link().callback(|_| DropdownMsg::Open) - }; - let overlay = if self.is_menu_active { - class.push("is-active"); - html! {
} - } else { - html! {} - }; + let overlay = if !props.hoverable && is_open && props.close_on_click_outside { html! { -
- {overlay} - -
- -
- {props.footer.clone()} -
-
- -
- - } -} + *close_reason.borrow_mut() = Some(reason); + close_dialog(&dialog_ref); -////////////////////////////////////////////////////////////////////////////// + if !is_controlled && let Some(controller) = controller.as_ref() { + controller.close(&modal_id); + return; + } -/// A request to close a modal instance by ID. -/// -/// The ID provided in this message must match the ID of the modal which is to be closed, else -/// the message will be ignored. -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct ModalCloseMsg(pub String); + set_local_open.emit(false); + if let Some(controller) = controller.as_ref() { + controller.close(&modal_id); + } + }) + }; -/// An agent used for being able to close `Modal` & `ModalCard` instances by ID. -/// -/// If custom modal closing functionality is need for your modal instance, the following -/// pattern is recommended. -/// -/// First, in your component which is using this modal, configure a `ModalCloser` dispatcher. -/// ```rust -/// use yew::agent::Dispatcher; -/// use yew::prelude::*; -/// // .. snip .. -/// fn create(props: Self::Properties, link: ComponentLink) -> Self { -/// let bridge = ModalCloser::dispatcher(); -/// Self { link, props, bridge } -/// } -/// ``` -/// -/// Next, in your component's `view` method, setup a callback to handle your component's close -/// event. ```rust -/// let closer = self.link.callback(|_| ModalCloseMsg("modal-0".into())); -/// // ... snip ... -/// {"Close"} -/// } -/// /> -/// ``` -/// -/// Finally, in your component's `update` method, send the `ModalCloseMsg` over to the agent which -/// will forward the message to the modal to cause it to close. -/// ```rust -/// fn update(&mut self, msg: Self::Message) -> ShouldRender { -/// self.bridge.send(msg); -/// true -/// } -/// ``` -/// -/// This pattern allows you to communicate with a modal by its given ID, allowing -/// you to close the modal from anywhere in your application. -pub struct ModalCloser { - link: WorkerLink, - subscribers: HashSet, -} + let bg_close = { + let close_action = close_action.clone(); + let close_on_backdrop = props.close_on_backdrop; + Callback::from(move |event: MouseEvent| { + if !close_on_backdrop { + return; + } + if should_ignore_target(&event) { + event.stop_propagation(); + return; + } + close_action.emit(ModalCloseReason::Backdrop); + }) + }; -impl Worker for ModalCloser { - type Input = ModalCloseMsg; - type Message = (); - // The agent receives requests to close modals by ID. - type Output = ModalCloseMsg; - type Reach = Public; + let delete_btn_close = { + let close_action = close_action.clone(); + Callback::from(move |_| close_action.emit(ModalCloseReason::CloseButton)) + }; + let escape_close = { + let close_action = close_action.clone(); + Callback::from(move |_| close_action.emit(ModalCloseReason::Escape)) + }; - // The agent forwards the input to all registered modals. + html! { + <> +
+ {props.trigger.clone()} +
- fn create(link: WorkerLink) -> Self { - Self { link, subscribers: HashSet::new() } + + + + + + } +} - fn update(&mut self, _: Self::Message) {} +/// Backwards-compatible alias for `ModalCard`. +#[component(ModalCard2)] +pub fn modal_card2(props: &ModalCardProps) -> Html { + html! { } +} - fn handle_input(&mut self, msg: Self::Input, _: HandlerId) { - for cmp in self.subscribers.iter() { - self.link.respond(*cmp, msg.clone()); - } - } +#[derive(Properties, Debug, PartialEq)] +pub struct ModalControllerProviderProps { + #[prop_or_default] + pub children: Children, +} - fn connected(&mut self, id: HandlerId) { - self.subscribers.insert(id); - } +/// Provides [`ModalControllerContext`] to descendants. +#[component] +pub fn ModalControllerProvider(props: &ModalControllerProviderProps) -> Html { + let state = use_reducer(ModalControllerState::default); + let controller = ModalController::new(state); - fn disconnected(&mut self, id: HandlerId) { - self.subscribers.remove(&id); + html! { + context={controller}> + { for props.children.iter() } + > } } diff --git a/src/components/navbar.rs b/src/components/navbar.rs index 1280583..1490d70 100644 --- a/src/components/navbar.rs +++ b/src/components/navbar.rs @@ -1,11 +1,33 @@ use derive_more::Display; +use std::rc::Rc; +use std::string::ToString; +use std::sync::atomic::{AtomicUsize, Ordering}; +use yew::events::{KeyboardEvent, MouseEvent}; use yew::prelude::*; -use crate::components::dropdown::DropdownMsg; +use crate::Button; + +static NAVBAR_AUTO_ID: AtomicUsize = AtomicUsize::new(1); +static NAVBAR_DROPDOWN_AUTO_ID: AtomicUsize = AtomicUsize::new(1); + +fn next_navbar_id() -> String { + format!("navbar-menu-{}", NAVBAR_AUTO_ID.fetch_add(1, Ordering::Relaxed)) +} + +fn next_navbar_dropdown_id() -> String { + format!("navbar-dropdown-{}", NAVBAR_DROPDOWN_AUTO_ID.fetch_add(1, Ordering::Relaxed)) +} + +#[derive(Clone, Eq, PartialEq)] +pub struct NavBurgerCloserState { + /// The total number of clicks received. + pub total_clicks: u32, +} /// The message type used by the `Navbar` component. pub enum NavbarMsg { ToggleMenu, + CloseEvent(Rc), } #[derive(Clone, Debug, Properties, PartialEq)] @@ -49,102 +71,199 @@ pub struct NavbarProps { /// Extra classes for the navbar burger. #[prop_or_default] pub navburger_classes: Classes, + /// Controlled open state for the mobile menu. + #[prop_or_default] + pub open: Option, + /// Controlled setter for the mobile menu. + #[prop_or_default] + pub set_open: Option>, + /// Called when the mobile menu opens. + #[prop_or_default] + pub on_open: Callback<()>, + /// Called when the mobile menu closes. + #[prop_or_default] + pub on_close: Callback<()>, + /// Allow closing the mobile menu with Escape. + #[prop_or(true)] + pub close_on_escape: bool, + /// Optional menu id used by `aria-controls`. + #[prop_or_default] + pub menu_id: Option, + /// Optional `aria-label` for the nav container. + #[prop_or_default] + pub aria_label: AttrValue, } /// A responsive horizontal navbar that can support images, links, buttons, and dropdowns. /// /// [https://bulma.io/documentation/components/navbar/](https://bulma.io/documentation/components/navbar/) -pub struct Navbar { - is_menu_open: bool, -} +#[component(Navbar)] +pub fn navbar(props: &NavbarProps) -> Html { + let internal_open = use_state(|| false); + let is_controlled = props.open.is_some() && props.set_open.is_some(); + let is_menu_open = props.open.unwrap_or(*internal_open); -impl Component for Navbar { - type Message = NavbarMsg; - type Properties = NavbarProps; + 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); + } + }) + }; - fn create(_ctx: &Context) -> Self { - Self { is_menu_open: false } + { + let on_open = props.on_open.clone(); + let on_close = props.on_close.clone(); + let prev_open = use_mut_ref(move || is_menu_open); + use_effect_with(is_menu_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; + } + || {} + }); } - fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { - match msg { - NavbarMsg::ToggleMenu => { - self.is_menu_open = !self.is_menu_open; + let closer_state = use_context::>(); + { + let set_local_open = set_local_open.clone(); + let prev_clicks = use_mut_ref(|| closer_state.as_ref().map(|state| state.total_clicks)); + use_effect_with(closer_state, move |state| { + let mut prev = prev_clicks.borrow_mut(); + let current = state.as_ref().map(|s| s.total_clicks); + if prev.is_some() && current != *prev { + set_local_open.emit(false); } - } - true + *prev = current; + || {} + }); } - fn view(&self, ctx: &Context) -> Html { - // navbar classes - let mut class = Classes::from("navbar"); - class.push(ctx.props().classes.clone()); - if let Some(fixed) = &ctx.props().fixed { - class.push(&fixed.to_string()); - } + let auto_menu_id = use_state(|| AttrValue::from(next_navbar_id())); + let menu_id = props.menu_id.clone().unwrap_or_else(|| (*auto_menu_id).clone()); + let aria_label = if props.aria_label.is_empty() { + AttrValue::from("main navigation") + } else { + props.aria_label.clone() + }; - // navbar-menu classes - let mut navclasses = Classes::from("navbar-menu"); - let mut burgerclasses = Classes::from("navbar-burger"); - burgerclasses.push(ctx.props().navburger_classes.clone()); - if self.is_menu_open { - navclasses.push("is-active"); - burgerclasses.push("is-active"); - } - let togglecb = ctx.link().callback(|_| NavbarMsg::ToggleMenu); - let navbrand = if let Some(navbrand) = &ctx.props().navbrand { - html! { - + } + } else { + Html::default() + }; + + let navstart = if let Some(navstart) = &props.navstart { + html! {} + } else { + Html::default() + }; + let navend = if let Some(navend) = &props.navend { + html! {} + } else { + Html::default() + }; + let contents = html! { + <> {navbrand} -
+
{navstart} {navend}
- - }; + + }; - if ctx.props().padded { - html! { - - } - } else { - html! { - - } + if props.padded { + html! { + + } + } else { + html! { + } } } @@ -156,11 +275,10 @@ impl Component for Navbar { /// NOTE WELL: in order to work properly, the root `html` or `body` element must be configured with /// the corresponding `has-navbar-fixed-top` or `has-navbar-fixed-bottom` class. #[derive(Clone, Debug, Display, PartialEq, Eq)] -#[display(fmt = "is-{}")] pub enum NavbarFixed { - #[display(fmt = "fixed-top")] + #[display("is-fixed-top")] Top, - #[display(fmt = "fixed-bottom")] + #[display("is-fixed-bottom")] Bottom, } @@ -172,9 +290,9 @@ pub enum NavbarFixed { /// [https://bulma.io/documentation/components/navbar/#navbar-item](https://bulma.io/documentation/components/navbar/#navbar-item) #[derive(Clone, Debug, Display, PartialEq, Eq)] pub enum NavbarItemTag { - #[display(fmt = "a")] + #[display("a")] A, - #[display(fmt = "div")] + #[display("div")] Div, } @@ -214,7 +332,7 @@ pub struct NavbarItemProps { /// A single element of the navbar. /// /// [https://bulma.io/documentation/components/navbar/](https://bulma.io/documentation/components/navbar/) -#[function_component(NavbarItem)] +#[component(NavbarItem)] pub fn navbar_item(props: &NavbarItemProps) -> Html { let class = classes!( "navbar-item", @@ -259,7 +377,7 @@ pub struct NavbarDividerProps { /// An element to display a horizontal rule in a navbar-dropdown. /// /// [https://bulma.io/documentation/components/navbar/#dropdown-menu](https://bulma.io/documentation/components/navbar/#dropdown-menu) -#[function_component(NavbarDivider)] +#[component(NavbarDivider)] pub fn navbar_divider(props: &NavbarDividerProps) -> Html { html! {
} } @@ -279,7 +397,7 @@ pub struct NavbarDropdownProps { /// Make this dropdown triggerable based on hover. #[prop_or_default] pub hoverable: bool, - /// Configure this manu to be a dropup. + /// Configure this menu to be a dropup. #[prop_or_default] pub dropup: bool, /// Render the contents of this dropdown to the right. @@ -291,6 +409,27 @@ pub struct NavbarDropdownProps { /// Use the boxed style for the dropdown, typically coupled with a transparent navbar. #[prop_or_default] pub boxed: bool, + /// Controlled open state. + #[prop_or_default] + pub open: Option, + /// Controlled open state setter. + #[prop_or_default] + pub set_open: Option>, + /// Callback emitted when opened. + #[prop_or_default] + pub on_open: Callback<()>, + /// Callback emitted when closed. + #[prop_or_default] + pub on_close: Callback<()>, + /// Allow closing with Escape. + #[prop_or(true)] + pub close_on_escape: bool, + /// Allow closing by clicking outside. + #[prop_or(true)] + pub close_on_click_outside: bool, + /// Optional id used to build ARIA links. + #[prop_or_default] + pub id: Option, } /// A navbar dropdown menu, which can include navbar items and dividers. @@ -299,72 +438,124 @@ pub struct NavbarDropdownProps { /// a navbar dropdown component. /// /// [https://bulma.io/documentation/components/navbar/#dropdown-menu](https://bulma.io/documentation/components/navbar/#dropdown-menu) -pub struct NavbarDropdown { - is_menu_active: bool, -} +#[component(NavbarDropdown)] +pub fn navbar_dropdown(props: &NavbarDropdownProps) -> Html { + let internal_open = use_state(|| false); + let is_controlled = props.open.is_some() && props.set_open.is_some(); + let is_menu_active = props.open.unwrap_or(*internal_open); -impl Component for NavbarDropdown { - type Message = DropdownMsg; - type Properties = NavbarDropdownProps; + 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); + } + }) + }; - fn create(_ctx: &Context) -> Self { - Self { is_menu_active: false } + { + let on_open = props.on_open.clone(); + let on_close = props.on_close.clone(); + let prev_open = use_mut_ref(move || is_menu_active); + use_effect_with(is_menu_active, 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; + } + || {} + }); } - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - if ctx.props().hoverable { - return false; - } - match msg { - DropdownMsg::Open => self.is_menu_active = true, - DropdownMsg::Close => self.is_menu_active = false, - } - true - } + let auto_id = use_state(|| AttrValue::from(next_navbar_dropdown_id())); + let root_id = props.id.clone().unwrap_or_else(|| (*auto_id).clone()); + let menu_id = AttrValue::from(format!("{}-menu", root_id)); - fn view(&self, ctx: &Context) -> Html { - // navbar-item classes - let mut class = Classes::from("navbar-item has-dropdown"); - class.push(ctx.props().classes.clone()); - if ctx.props().dropup { - class.push("has-dropdown-up"); - } + // navbar-item classes + let class = classes!( + "navbar-item", + "has-dropdown", + props.classes.clone(), + props.dropup.then_some("has-dropdown-up"), + props.hoverable.then_some("is-hoverable"), + (!props.hoverable && is_menu_active).then_some("is-active"), + ); - // navbar-dropdown classes - let mut dropclasses = Classes::from("navbar-dropdown"); - if ctx.props().right { - dropclasses.push("is-right"); - } - if ctx.props().boxed { - dropclasses.push("is-boxed"); - } + // navbar-dropdown classes + let dropclasses = classes!("navbar-dropdown", props.right.then_some("is-right"), props.boxed.then_some("is-boxed"),); - // navbar-link classes - let mut linkclasses = Classes::from("navbar-link"); - if ctx.props().arrowless { - linkclasses.push("is-arrowless"); - } + // navbar-link classes + let linkclasses = classes!("navbar-link", props.arrowless.then_some("is-arrowless")); - let opencb = if ctx.props().hoverable { - class.push("is-hoverable"); - Callback::noop() - } else { - ctx.link().callback(|_| DropdownMsg::Open) - }; - let overlay = if self.is_menu_active { - class.push("is-active"); - html! {
} - } else { - html! {} - }; + let on_trigger_click = { + let set_local_open = set_local_open.clone(); + let hoverable = props.hoverable; + let is_menu_active = is_menu_active; + Callback::from(move |event: MouseEvent| { + if hoverable { + return; + } + event.prevent_default(); + set_local_open.emit(!is_menu_active); + }) + }; + + let on_trigger_keydown = { + let set_local_open = set_local_open.clone(); + let hoverable = props.hoverable; + let close_on_escape = props.close_on_escape; + Callback::from(move |event: KeyboardEvent| match event.key().as_str() { + "Enter" | " " | "ArrowDown" if !hoverable => { + event.prevent_default(); + set_local_open.emit(true); + } + "Escape" if close_on_escape => { + event.prevent_default(); + set_local_open.emit(false); + } + _ => {} + }) + }; + + let overlay = if !props.hoverable && is_menu_active && props.close_on_click_outside { + let set_local_open = set_local_open.clone(); html! { -
- {overlay} - {ctx.props().navlink.clone()} -
- {ctx.props().children.clone()} -
-
+
} + } else { + Html::default() + }; + + html! { +
+ {overlay} + + {props.navlink.clone()} + + +
} } diff --git a/src/components/pagination.rs b/src/components/pagination.rs index 3145d28..9d35da6 100644 --- a/src/components/pagination.rs +++ b/src/components/pagination.rs @@ -30,7 +30,7 @@ pub struct PaginationProps { /// A responsive, usable, and flexible pagination component. /// /// [https://bulma.io/documentation/components/pagination/](https://bulma.io/documentation/components/pagination/) -#[function_component(Pagination)] +#[component(Pagination)] pub fn pagination(props: &PaginationProps) -> Html { let class = classes!( "pagination", @@ -64,15 +64,21 @@ pub struct PaginationItemProps { /// The click handler for this component. #[prop_or_default] pub onclick: Callback, + #[prop_or_default] + pub current: bool, } /// A pagination element representing a link to a page number, the previous page or the next page. /// /// [https://bulma.io/documentation/components/pagination/](https://bulma.io/documentation/components/pagination/) -#[function_component(PaginationItem)] +#[component(PaginationItem)] pub fn pagination_item(props: &PaginationItemProps) -> Html { + let effective_class = match props.current { + true => format!("{} is-current", props.item_type), + false => props.item_type.to_string(), + }; html! { - + {props.children.clone()} } @@ -80,16 +86,15 @@ pub fn pagination_item(props: &PaginationItemProps) -> Html { /// A pagination item type. #[derive(Clone, Debug, Display, PartialEq, Eq)] -#[display(fmt = "pagination-{}")] pub enum PaginationItemType { /// A pagination link for a specific page number. - #[display(fmt = "link")] + #[display("pagination-link")] Link, /// A pagination button for the next page. - #[display(fmt = "next")] + #[display("pagination-next")] Next, /// A pagination button for the previous page. - #[display(fmt = "previous")] + #[display("pagination-previous")] Previous, } @@ -107,7 +112,7 @@ pub struct PaginationEllipsisProps { /// A horizontal ellipsis for pagination range separators. /// /// [https://bulma.io/documentation/components/pagination/](https://bulma.io/documentation/components/pagination/) -#[function_component(PaginationEllipsis)] +#[component(PaginationEllipsis)] pub fn pagination_ellipsis(props: &PaginationEllipsisProps) -> Html { html! {{&props.character}} } @@ -119,8 +124,8 @@ pub fn pagination_ellipsis(props: &PaginationEllipsisProps) -> Html { mod router { use super::*; use serde::Serialize; - use yew_router::components::Link; use yew_router::Routable; + use yew_router::components::Link; #[derive(Clone, Properties, PartialEq)] pub struct RouterProps { diff --git a/src/components/panel.rs b/src/components/panel.rs index 821d095..0813209 100644 --- a/src/components/panel.rs +++ b/src/components/panel.rs @@ -1,4 +1,4 @@ -use yew::events::MouseEvent; +use yew::events::{KeyboardEvent, MouseEvent}; use yew::prelude::*; #[derive(Clone, Debug, Properties, PartialEq)] @@ -10,16 +10,20 @@ pub struct PanelProps { /// The HTML content of this panel's heading; it is automatically wrapped in a `p.panel-heading`. #[prop_or_default] pub heading: Html, + /// Optional accessible label for this panel navigation. + #[prop_or_default] + pub aria_label: AttrValue, } /// A composable panel, for compact controls. /// /// [https://bulma.io/documentation/components/panel/](https://bulma.io/documentation/components/panel/) -#[function_component(Panel)] +#[component(Panel)] pub fn panel(props: &PanelProps) -> Html { let class = classes!("panel", props.classes.clone()); + let aria_label = (!props.aria_label.is_empty()).then_some(props.aria_label.clone()); html! { -