diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index ae09f70..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,3 +0,0 @@ -[build] -rustflags = ["--cfg=web_sys_unstable_apis"] -rustdocflags = ["--cfg=web_sys_unstable_apis"] \ No newline at end of file diff --git a/.fleet/run.json b/.fleet/run.json index 4e4ffb5..a849842 100644 --- a/.fleet/run.json +++ b/.fleet/run.json @@ -3,14 +3,14 @@ { "type": "cargo", "name": "Tests", - "cargoArgs": ["test", "--features", "math,storage,docs"], + "cargoArgs": ["test", "--features", "math,prost,serde,docs"], }, { "type": "cargo", "name": "Clippy", - "cargoArgs": ["+nightly", "clippy", "--features", "math,storage,docs", "--tests", "--", "-D", "warnings"], + "cargoArgs": ["+nightly", "clippy", "--features", "math,prost,serde,docs", "--tests", "--", "-D", "warnings"], "workingDir": "./", }, ] -} \ No newline at end of file +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d883bd8..6ad089d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: - name: Check formatting run: cargo fmt --check - name: Clippy - run: cargo clippy --features storage,docs,math --tests -- -D warnings + run: cargo clippy --features prost,serde,docs,math --tests -- -D warnings - name: Run tests run: cargo test --all-features @@ -110,4 +110,4 @@ jobs: # - name: Publish to Coveralls # uses: coverallsapp/github-action@master # with: -# github-token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file +# github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 70a765e..f5500fb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,6 +23,6 @@ jobs: - name: Check formatting run: cargo fmt --check - name: Clippy - run: cargo clippy --features storage,docs,math --tests -- -D warnings + run: cargo clippy --features prost,serde,docs,math --tests -- -D warnings - name: Run tests run: cargo test --all-features diff --git a/.idea/leptos-use.iml b/.idea/leptos-use.iml index c2e65fb..8da99c2 100644 --- a/.idea/leptos-use.iml +++ b/.idea/leptos-use.iml @@ -12,6 +12,7 @@ + @@ -56,6 +57,10 @@ + + + + @@ -65,7 +70,9 @@ + + @@ -126,4 +133,4 @@ - \ No newline at end of file + diff --git a/CHANGELOG.md b/CHANGELOG.md index 28fa3e7..9401b3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,80 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.0] - 2023-12-06 + +### New Functions 🚀 + +- `use_display_media` (thanks to @seanaye) + +### Breaking Changes 🛠 + +- (@feral-dot-io) The use `use__storage` functions have been rewritten to use `Codec`s instead of always + requiring `serde`. + - This also removes the feature `storage` + - By default the `StringCodec` is used which relies on types implementing `FromString + ToString` + - If you want to use `JsonCodec` you have to enable the feature `serde` + - If you want to use `ProstCodec` (new!) you have to enable the feature `prost`. +- (@feral-dot-io) The Rust flag `--cfg=web_sys_unstable_apis` is not needed anymore since relevant `web_sys` APIs are + now stable. + This affects in particular + - `use_element_size` + - `use_resize_observer` + +### Fixes 🍕 + +- `use_raf_fn` and `use_timestamp` no longer spam warnings because of `get`ting signals outside of reactive contexts. +- `use_infinite_scroll` no longer calls the callback twice for the same event +- `use_scroll` now uses `try_get_untracked` in the debounced callback to avoid panics if the context has been destroyed + while the callback was waiting to be called. +- `use_idle` works properly now (no more idles too early). +- `use_web_notification` doesn't panic on the server anymore. + +## [0.8.2] - 2023-11-09 + +### Fixes 🍕 + +- Fixed SSR for + - use_timestamp + - use_raf_fn + - use_idle + +## [0.8.1] - 2023-10-28 + +### Fixes 🍕 + +- Using strings for `ElementMaybeSignal` and `ElementsMaybeSignal` is now SSR safe. + - This fixes specifically `use_color_mode` to work on the server. + +## [0.8.0] - 2023-10-24 + +### New Functions 🚀 + +- `use_web_notification` (thanks to @centershocks44) +- `use_infinite_scroll` +- `use_service_worker` (thanks to @lpotthast) + +### Breaking Changes 🛠 + +- `use_scroll` returns `impl Fn(T) + Clone` instead of `Box`. + +### Other Changes 🔥 + +- `UseScrollReturn` is now documented + +## [0.7.2] - 2023-10-21 + +### Fixes 🍕 + +- Some functions still used `window()` which could lead to panics in SSR. This is now fixed. + Specifically for `use_draggable`. + +## [0.7.1] - 2023-10-02 + +### New Function 🚀 + +- `use_sorted` + ## [0.7.0] - 2023-09-30 ### New Functions 🚀 diff --git a/Cargo.toml b/Cargo.toml index 444bf29..473be60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "leptos-use" -version = "0.7.0" +version = "0.9.0" edition = "2021" authors = ["Marc-Stefan Cassola"] categories = ["gui", "web-programming"] @@ -13,23 +13,30 @@ repository = "https://github.com/Synphonyte/leptos-use" homepage = "https://leptos-use.rs" [dependencies] -leptos = "0.5" -wasm-bindgen = "0.2" -js-sys = "0.3" +base64 = { version = "0.21", optional = true } +cfg-if = "1" default-struct-builder = "0.5" +futures-util = "0.3" +gloo-timers = { version = "0.3.0", features = ["futures"] } +gloo-utils = { version = "0.2.0" } +js-sys = "0.3" +lazy_static = "1" +leptos = "0.5" num = { version = "0.4", optional = true } +paste = "1" +prost = { version = "0.12", optional = true } serde = { version = "1", optional = true } serde_json = { version = "1", optional = true } -paste = "1" -lazy_static = "1" -cfg-if = "1" +thiserror = "1.0" +wasm-bindgen = "0.2.88" +wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4" async-trait = "0.1" rmp-serde = { version = "1.1", optional = true } thiserror = "1" [dependencies.web-sys] -version = "0.3" +version = "0.3.65" features = [ "AddEventListenerOptions", "BinaryType", @@ -38,6 +45,7 @@ features = [ "CssStyleDeclaration", "CustomEvent", "CustomEventInit", + "DisplayMediaStreamConstraints", "DomRect", "DomRectReadOnly", "DataTransfer", @@ -55,13 +63,20 @@ features = [ "IntersectionObserver", "IntersectionObserverInit", "IntersectionObserverEntry", + "MediaDevices", "MediaQueryList", + "MediaStream", + "MediaStreamTrack", "MouseEvent", "MutationObserver", "MutationObserverInit", "MutationRecord", "Navigator", "NodeList", + "Notification", + "NotificationDirection", + "NotificationOptions", + "NotificationPermission", "PointerEvent", "Position", "PositionError", @@ -77,7 +92,12 @@ features = [ "ResizeObserverSize", "ScrollBehavior", "ScrollToOptions", + "ServiceWorker", + "ServiceWorkerContainer", + "ServiceWorkerRegistration", + "ServiceWorkerState", "Storage", + "StorageEvent", "Touch", "TouchEvent", "TouchList", @@ -97,12 +117,10 @@ features = [ [features] docs = [] math = ["num"] -storage = ["dep:serde", "dep:serde_json", "web-sys/StorageEvent"] +prost = ["base64", "dep:prost"] +serde = ["dep:serde", "serde_json"] ssr = [] msgpack = ["dep:rmp-serde", "dep:serde"] [package.metadata.docs.rs] all-features = true -rustdoc-args = ["--cfg=web_sys_unstable_apis"] -rustc-args = ["--cfg=web_sys_unstable_apis"] - diff --git a/README.md b/README.md index bbfed70..7fa64c7 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Crates.io SSR Docs & Demos - 58 Functions + 63 Functions


@@ -87,8 +87,8 @@ This will create the function file in the src directory, scaffold an example dir ## Leptos compatibility -| Crate version | Compatible Leptos version | -|---------------|---------------------------| -| <= 0.3 | 0.3 | -| 0.4, 0.5, 0.6 | 0.4 | -| 0.7 | 0.5 | +| Crate version | Compatible Leptos version | +|----------------|---------------------------| +| <= 0.3 | 0.3 | +| 0.4, 0.5, 0.6 | 0.4 | +| 0.7, 0.8, 0.9 | 0.5 | diff --git a/docs/book/post_build.py b/docs/book/post_build.py index 4185033..1abc3a2 100644 --- a/docs/book/post_build.py +++ b/docs/book/post_build.py @@ -2,6 +2,7 @@ import os import shutil import subprocess import sys +import re def main(): @@ -43,7 +44,7 @@ def build_and_copy_demo(category, md_name): html = f.read() head_split = html.split("") target_head = head_split[1].split("")[0] - body_split = html.split("")[1].split("") + body_split = re.split("]*>", html)[1].split("") target_body = body_split[0] with open(book_html_path, "w") as f: diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index a01e745..bd3899c 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -6,7 +6,7 @@ [Changelog](changelog.md) [Functions](functions.md) -# @Storage +# Storage - [use_local_storage](storage/use_local_storage.md) - [use_session_storage](storage/use_session_storage.md) @@ -33,11 +33,14 @@ - [use_breakpoints](browser/use_breakpoints.md) - [use_color_mode](browser/use_color_mode.md) - [use_css_var](browser/use_css_var.md) +- [use_display_media](browser/use_display_media.md) - [use_event_listener](browser/use_event_listener.md) - [use_favicon](browser/use_favicon.md) - [use_media_query](browser/use_media_query.md) - [use_preferred_contrast](browser/use_preferred_contrast.md) - [use_preferred_dark](browser/use_preferred_dark.md) +- [use_service_worker](browser/use_service_worker.md) +- [use_web_notification](browser/use_web_notification.md) # Sensors @@ -45,6 +48,7 @@ - [use_element_hover](sensors/use_element_hover.md) - [use_geolocation](sensors/use_geolocation.md) - [use_idle](sensors/use_idle.md) +- [use_infinite_scroll](sensors/use_infinite_scroll.md) - [use_mouse](sensors/use_mouse.md) - [use_scroll](sensors/use_scroll.md) @@ -73,8 +77,11 @@ - [signal_debounced](reactivity/signal_debounced.md) - [signal_throttled](reactivity/signal_throttled.md) -# Utilities +# Iterable +- [use_sorted](iterable/use_sorted.md) + +# Utilities - [is_err](utilities/is_err.md) - [is_none](utilities/is_none.md) - [is_ok](utilities/is_ok.md) diff --git a/docs/book/src/browser/use_display_media.md b/docs/book/src/browser/use_display_media.md new file mode 100644 index 0000000..6a2d3ed --- /dev/null +++ b/docs/book/src/browser/use_display_media.md @@ -0,0 +1,3 @@ +# use_display_media + + diff --git a/docs/book/src/browser/use_service_worker.md b/docs/book/src/browser/use_service_worker.md new file mode 100644 index 0000000..93f8a0a --- /dev/null +++ b/docs/book/src/browser/use_service_worker.md @@ -0,0 +1,3 @@ +# use_service_worker + + diff --git a/docs/book/src/browser/use_web_notification.md b/docs/book/src/browser/use_web_notification.md new file mode 100644 index 0000000..ceef60c --- /dev/null +++ b/docs/book/src/browser/use_web_notification.md @@ -0,0 +1,3 @@ +# use_web_notification + + diff --git a/docs/book/src/custom.css b/docs/book/src/custom.css index 7627d06..fe7c8a4 100644 --- a/docs/book/src/custom.css +++ b/docs/book/src/custom.css @@ -99,4 +99,8 @@ h1 { h2, h3, h4 { font-weight: 600; +} + +#searchbar { + font-size: 1.6rem; } \ No newline at end of file diff --git a/docs/book/src/introduction.md b/docs/book/src/introduction.md index 5824f3a..a8c07ae 100644 --- a/docs/book/src/introduction.md +++ b/docs/book/src/introduction.md @@ -12,6 +12,6 @@ Crates.io SSR Docs & Demos - 58 Functions + 63 Functions

\ No newline at end of file diff --git a/docs/book/src/iterable/use_sorted.md b/docs/book/src/iterable/use_sorted.md new file mode 100644 index 0000000..ad6b740 --- /dev/null +++ b/docs/book/src/iterable/use_sorted.md @@ -0,0 +1,3 @@ +# use_sorted + + diff --git a/docs/book/src/sensors/use_infinite_scroll.md b/docs/book/src/sensors/use_infinite_scroll.md new file mode 100644 index 0000000..1810384 --- /dev/null +++ b/docs/book/src/sensors/use_infinite_scroll.md @@ -0,0 +1,3 @@ +# use_infinite_scroll + + diff --git a/docs/book/src/storage/use_local_storage.md b/docs/book/src/storage/use_local_storage.md index d39b415..3256f85 100644 --- a/docs/book/src/storage/use_local_storage.md +++ b/docs/book/src/storage/use_local_storage.md @@ -1,3 +1,3 @@ # use_local_storage - + diff --git a/docs/book/src/storage/use_session_storage.md b/docs/book/src/storage/use_session_storage.md index f04e6e5..03ddbab 100644 --- a/docs/book/src/storage/use_session_storage.md +++ b/docs/book/src/storage/use_session_storage.md @@ -1,3 +1,3 @@ # use_session_storage - + diff --git a/docs/book/src/storage/use_storage.md b/docs/book/src/storage/use_storage.md index f937ee4..316df04 100644 --- a/docs/book/src/storage/use_storage.md +++ b/docs/book/src/storage/use_storage.md @@ -1,3 +1,3 @@ # use_storage - + diff --git a/examples/.cargo/config.toml b/examples/.cargo/config.toml deleted file mode 100644 index c87f326..0000000 --- a/examples/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[build] -rustflags = ["--cfg=web_sys_unstable_apis", "--cfg=has_std"] diff --git a/examples/Cargo.toml b/examples/Cargo.toml index b9e9dfe..f1a3572 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -13,6 +13,7 @@ members = [ "use_css_var", "use_cycle_list", "use_debounce_fn", + "use_display_media", "use_document_visibility", "use_draggable", "use_drop_zone", @@ -24,6 +25,7 @@ members = [ "use_floor", "use_geolocation", "use_idle", + "use_infinite_scroll", "use_intersection_observer", "use_interval", "use_interval_fn", @@ -35,9 +37,12 @@ members = [ "use_resize_observer", "use_round", "use_scroll", + "use_service_worker", + "use_sorted", "use_storage", "use_throttle_fn", "use_timestamp", + "use_web_notification", "use_websocket", "use_webtransport", "use_window_focus", diff --git a/examples/signal_debounced/src/main.rs b/examples/signal_debounced/src/main.rs index 4e42d6c..cce81fc 100644 --- a/examples/signal_debounced/src/main.rs +++ b/examples/signal_debounced/src/main.rs @@ -15,17 +15,9 @@ fn Demo() -> impl IntoView { on:input=move |event| set_input(event_target_value(&event)) placeholder="Try to type quickly, then stop..." /> - - Delay is set to 1000ms for this demo. - -

- Input signal: - {input} -

-

- Debounced signal: - {debounced} -

+ Delay is set to 1000ms for this demo. +

Input signal: {input}

+

Debounced signal: {debounced}

} } diff --git a/examples/signal_throttled/src/main.rs b/examples/signal_throttled/src/main.rs index 2ff7366..0fc9058 100644 --- a/examples/signal_throttled/src/main.rs +++ b/examples/signal_throttled/src/main.rs @@ -15,17 +15,9 @@ fn Demo() -> impl IntoView { on:input=move |event| set_input(event_target_value(&event)) placeholder="Try to type quickly..." /> - - Delay is set to 1000ms for this demo. - -

- Input signal: - {input} -

-

- Throttled signal: - {throttled} -

+ Delay is set to 1000ms for this demo. +

Input signal: {input}

+

Throttled signal: {throttled}

} } diff --git a/examples/ssr/Cargo.toml b/examples/ssr/Cargo.toml index 7b30d87..d37a754 100644 --- a/examples/ssr/Cargo.toml +++ b/examples/ssr/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "start-axum" +name = "leptos-use-ssr" version = "0.1.0" edition = "2021" @@ -15,13 +15,13 @@ leptos = { version = "0.5", features = ["nightly"] } leptos_axum = { version = "0.5", optional = true } leptos_meta = { version = "0.5", features = ["nightly"] } leptos_router = { version = "0.5", features = ["nightly"] } -leptos-use = { path = "../..", features = ["storage"] } +leptos-use = { path = "../.." } log = "0.4" simple_logger = "4" tokio = { version = "1.25.0", optional = true } tower = { version = "0.4.13", optional = true } tower-http = { version = "0.4.3", features = ["fs"], optional = true } -wasm-bindgen = "=0.2.87" +wasm-bindgen = "0.2.88" thiserror = "1.0.38" tracing = { version = "0.1.37", optional = true } http = "0.2.8" diff --git a/examples/ssr/src/app.rs b/examples/ssr/src/app.rs index 91e95b8..cdfe5e3 100644 --- a/examples/ssr/src/app.rs +++ b/examples/ssr/src/app.rs @@ -3,10 +3,10 @@ use leptos::ev::{keypress, KeyboardEvent}; use leptos::*; use leptos_meta::*; use leptos_router::*; -use leptos_use::storage::use_local_storage; +use leptos_use::storage::{use_local_storage, StringCodec}; use leptos_use::{ - use_debounce_fn, use_event_listener, use_intl_number_format, use_window, - UseIntlNumberFormatOptions, + use_color_mode, use_debounce_fn, use_event_listener, use_intl_number_format, use_timestamp, + use_window, ColorMode, UseColorModeReturn, UseIntlNumberFormatOptions, }; #[component] @@ -37,7 +37,7 @@ pub fn App() -> impl IntoView { #[component] fn HomePage() -> impl IntoView { // Creates a reactive value to update the button - let (count, set_count, _) = use_local_storage("count-state", 0); + let (count, set_count, _) = use_local_storage::("count-state"); let on_click = move |_| set_count.update(|count| *count += 1); let nf = use_intl_number_format( @@ -63,25 +63,20 @@ fn HomePage() -> impl IntoView { ); debounced_fn(); + let UseColorModeReturn { mode, set_mode, .. } = use_color_mode(); + + let timestamp = use_timestamp(); + view! { -

- Leptos-Use SSR Example -

- -

- Locale zh-Hans-CN-u-nu-hanidec: - {zh_count} -

-

- Press any key: - {key} -

-

- Debounced called: - {debounce_value} -

+

Leptos-Use SSR Example

+ +

Locale zh-Hans-CN-u-nu-hanidec: {zh_count}

+

Press any key: {key}

+

Debounced called: {debounce_value}

+

Color mode: {move || format!("{:?}", mode.get())}

+ + + +

{timestamp}

} } diff --git a/examples/ssr/src/main.rs b/examples/ssr/src/main.rs index 6b92404..c08d00e 100644 --- a/examples/ssr/src/main.rs +++ b/examples/ssr/src/main.rs @@ -5,8 +5,8 @@ async fn main() { use leptos::logging::log; use leptos::*; use leptos_axum::{generate_route_list, LeptosRoutes}; - use start_axum::app::*; - use start_axum::fileserv::file_and_error_handler; + use leptos_use_ssr::app::*; + use leptos_use_ssr::fileserv::file_and_error_handler; simple_logger::init_with_level(log::Level::Debug).expect("couldn't initialize logging"); @@ -18,7 +18,7 @@ async fn main() { let conf = get_configuration(None).await.unwrap(); let leptos_options = conf.leptos_options; let addr = leptos_options.site_addr; - let routes = generate_route_list(|| view! { }).await; + let routes = generate_route_list(|| view! { }); // build our application with a route let app = Router::new() diff --git a/examples/ssr/style/main.scss b/examples/ssr/style/main.scss index e4538e1..24ed267 100644 --- a/examples/ssr/style/main.scss +++ b/examples/ssr/style/main.scss @@ -1,4 +1,9 @@ body { - font-family: sans-serif; - text-align: center; + font-family: sans-serif; + text-align: center; +} + +.dark { + background-color: black; + color: white; } \ No newline at end of file diff --git a/examples/use_active_element/src/main.rs b/examples/use_active_element/src/main.rs index e18fe28..8678475 100644 --- a/examples/use_active_element/src/main.rs +++ b/examples/use_active_element/src/main.rs @@ -19,11 +19,7 @@ fn Demo() -> impl IntoView { "Select the inputs below to see the changes"
- + diff --git a/examples/use_color_mode/Cargo.toml b/examples/use_color_mode/Cargo.toml index 308c9c2..0b58b63 100644 --- a/examples/use_color_mode/Cargo.toml +++ b/examples/use_color_mode/Cargo.toml @@ -8,7 +8,7 @@ leptos = { version = "0.5", features = ["nightly", "csr"] } console_error_panic_hook = "0.1" console_log = "1" log = "0.4" -leptos-use = { path = "../..", features = ["docs", "storage"] } +leptos-use = { path = "../..", features = ["docs"] } web-sys = "0.3" [dev-dependencies] diff --git a/examples/use_display_media/Cargo.toml b/examples/use_display_media/Cargo.toml new file mode 100644 index 0000000..32014f2 --- /dev/null +++ b/examples/use_display_media/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "use_display_media" +version = "0.1.0" +edition = "2021" + +[dependencies] +leptos = { version = "0.5", features = ["nightly", "csr"] } +console_error_panic_hook = "0.1" +console_log = "1" +log = "0.4" +leptos-use = { path = "../..", features = ["docs"] } +web-sys = "0.3" + +[dev-dependencies] +wasm-bindgen = "0.2" +wasm-bindgen-test = "0.3.0" diff --git a/examples/use_display_media/README.md b/examples/use_display_media/README.md new file mode 100644 index 0000000..3cc0e27 --- /dev/null +++ b/examples/use_display_media/README.md @@ -0,0 +1,23 @@ +A simple example for `use_display_media`. + +If you don't have it installed already, install [Trunk](https://trunkrs.dev/) and [Tailwind](https://tailwindcss.com/docs/installation) +as well as the nightly toolchain for Rust and the wasm32-unknown-unknown target: + +```bash +cargo install trunk +npm install -D tailwindcss @tailwindcss/forms +rustup toolchain install nightly +rustup target add wasm32-unknown-unknown +``` + +Then, open two terminals. In the first one, run: + +``` +npx tailwindcss -i ./input.css -o ./style/output.css --watch +``` + +In the second one, run: + +```bash +trunk serve --open +``` \ No newline at end of file diff --git a/examples/use_display_media/Trunk.toml b/examples/use_display_media/Trunk.toml new file mode 100644 index 0000000..3e4be08 --- /dev/null +++ b/examples/use_display_media/Trunk.toml @@ -0,0 +1,2 @@ +[build] +public_url = "/demo/" \ No newline at end of file diff --git a/examples/use_display_media/index.html b/examples/use_display_media/index.html new file mode 100644 index 0000000..ae249a6 --- /dev/null +++ b/examples/use_display_media/index.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/examples/use_display_media/input.css b/examples/use_display_media/input.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/examples/use_display_media/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/examples/use_display_media/rust-toolchain.toml b/examples/use_display_media/rust-toolchain.toml new file mode 100644 index 0000000..271800c --- /dev/null +++ b/examples/use_display_media/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" \ No newline at end of file diff --git a/examples/use_display_media/src/main.rs b/examples/use_display_media/src/main.rs new file mode 100644 index 0000000..d1d50d8 --- /dev/null +++ b/examples/use_display_media/src/main.rs @@ -0,0 +1,57 @@ +use leptos::*; +use leptos_use::docs::demo_or_body; +use leptos_use::{use_display_media, UseDisplayMediaReturn}; + +#[component] +fn Demo() -> impl IntoView { + let video_ref = create_node_ref::(); + + let UseDisplayMediaReturn { + stream, + enabled, + set_enabled, + .. + } = use_display_media(); + + create_effect(move |_| { + match stream.get() { + Some(Ok(s)) => { + video_ref.get().map(|v| v.set_src_object(Some(&s))); + return; + } + Some(Err(e)) => logging::error!("Failed to get media stream: {:?}", e), + None => logging::log!("No stream yet"), + } + + video_ref.get().map(|v| v.set_src_object(None)); + }); + + view! { +
+
+ +
+ +
+ +
+
+ } +} + +fn main() { + _ = console_log::init_with_level(log::Level::Debug); + console_error_panic_hook::set_once(); + + mount_to(demo_or_body(), || { + view! { } + }) +} diff --git a/examples/use_display_media/style/output.css b/examples/use_display_media/style/output.css new file mode 100644 index 0000000..291c6c5 --- /dev/null +++ b/examples/use_display_media/style/output.css @@ -0,0 +1,342 @@ +[type='text'],input:where(:not([type])),[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: #fff; + border-color: #6b7280; + border-width: 1px; + border-radius: 0px; + padding-top: 0.5rem; + padding-right: 0.75rem; + padding-bottom: 0.5rem; + padding-left: 0.75rem; + font-size: 1rem; + line-height: 1.5rem; + --tw-shadow: 0 0 #0000; +} + +[type='text']:focus, input:where(:not([type])):focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: #2563eb; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + border-color: #2563eb; +} + +input::-moz-placeholder, textarea::-moz-placeholder { + color: #6b7280; + opacity: 1; +} + +input::placeholder,textarea::placeholder { + color: #6b7280; + opacity: 1; +} + +::-webkit-datetime-edit-fields-wrapper { + padding: 0; +} + +::-webkit-date-and-time-value { + min-height: 1.5em; + text-align: inherit; +} + +::-webkit-datetime-edit { + display: inline-flex; +} + +::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field { + padding-top: 0; + padding-bottom: 0; +} + +select { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); + background-position: right 0.5rem center; + background-repeat: no-repeat; + background-size: 1.5em 1.5em; + padding-right: 2.5rem; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; +} + +[multiple],[size]:where(select:not([size="1"])) { + background-image: initial; + background-position: initial; + background-repeat: unset; + background-size: initial; + padding-right: 0.75rem; + -webkit-print-color-adjust: unset; + print-color-adjust: unset; +} + +[type='checkbox'],[type='radio'] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + padding: 0; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + display: inline-block; + vertical-align: middle; + background-origin: border-box; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + flex-shrink: 0; + height: 1rem; + width: 1rem; + color: #2563eb; + background-color: #fff; + border-color: #6b7280; + border-width: 1px; + --tw-shadow: 0 0 #0000; +} + +[type='checkbox'] { + border-radius: 0px; +} + +[type='radio'] { + border-radius: 100%; +} + +[type='checkbox']:focus,[type='radio']:focus { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); + --tw-ring-offset-width: 2px; + --tw-ring-offset-color: #fff; + --tw-ring-color: #2563eb; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); +} + +[type='checkbox']:checked,[type='radio']:checked { + border-color: transparent; + background-color: currentColor; + background-size: 100% 100%; + background-position: center; + background-repeat: no-repeat; +} + +[type='checkbox']:checked { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); +} + +@media (forced-colors: active) { + [type='checkbox']:checked { + -webkit-appearance: auto; + -moz-appearance: auto; + appearance: auto; + } +} + +[type='radio']:checked { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); +} + +@media (forced-colors: active) { + [type='radio']:checked { + -webkit-appearance: auto; + -moz-appearance: auto; + appearance: auto; + } +} + +[type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus { + border-color: transparent; + background-color: currentColor; +} + +[type='checkbox']:indeterminate { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e"); + border-color: transparent; + background-color: currentColor; + background-size: 100% 100%; + background-position: center; + background-repeat: no-repeat; +} + +@media (forced-colors: active) { + [type='checkbox']:indeterminate { + -webkit-appearance: auto; + -moz-appearance: auto; + appearance: auto; + } +} + +[type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus { + border-color: transparent; + background-color: currentColor; +} + +[type='file'] { + background: unset; + border-color: inherit; + border-width: 0; + border-radius: 0; + padding: 0; + font-size: unset; + line-height: inherit; +} + +[type='file']:focus { + outline: 1px solid ButtonText; + outline: 1px auto -webkit-focus-ring-color; +} + +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +.static { + position: static; +} + +.flex { + display: flex; +} + +.h-96 { + height: 24rem; +} + +.w-auto { + width: auto; +} + +.flex-col { + flex-direction: column; +} + +.gap-4 { + gap: 1rem; +} + +.text-center { + text-align: center; +} + +.text-\[--brand-color\] { + color: var(--brand-color); +} + +.text-green-600 { + --tw-text-opacity: 1; + color: rgb(22 163 74 / var(--tw-text-opacity)); +} + +.opacity-75 { + opacity: 0.75; +} + +@media (prefers-color-scheme: dark) { + .dark\:text-green-500 { + --tw-text-opacity: 1; + color: rgb(34 197 94 / var(--tw-text-opacity)); + } +} \ No newline at end of file diff --git a/examples/use_display_media/tailwind.config.js b/examples/use_display_media/tailwind.config.js new file mode 100644 index 0000000..bc09f5e --- /dev/null +++ b/examples/use_display_media/tailwind.config.js @@ -0,0 +1,15 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: { + files: ["*.html", "./src/**/*.rs", "../../src/docs/**/*.rs"], + }, + theme: { + extend: {}, + }, + corePlugins: { + preflight: false, + }, + plugins: [ + require('@tailwindcss/forms'), + ], +} \ No newline at end of file diff --git a/examples/use_draggable/src/main.rs b/examples/use_draggable/src/main.rs index cf92cdb..f7cec80 100644 --- a/examples/use_draggable/src/main.rs +++ b/examples/use_draggable/src/main.rs @@ -2,13 +2,16 @@ use leptos::html::Div; use leptos::*; use leptos_use::core::Position; use leptos_use::docs::demo_or_body; -use leptos_use::{use_draggable_with_options, UseDraggableOptions, UseDraggableReturn}; +use leptos_use::{use_draggable_with_options, use_window, UseDraggableOptions, UseDraggableReturn}; #[component] fn Demo() -> impl IntoView { let el = create_node_ref::
(); - let inner_width = window().inner_width().unwrap().as_f64().unwrap(); + let inner_width = use_window() + .as_ref() + .map(|w| w.inner_width().unwrap().as_f64().unwrap()) + .unwrap_or(0.0); let UseDraggableReturn { x, y, style, .. } = use_draggable_with_options( el, @@ -21,20 +24,14 @@ fn Demo() -> impl IntoView { ); view! { -

- Check the floating box -

+

Check the floating box

"👋 Drag me!" -
- I am - {move || x().round()}, - {move || y().round()} -
+
I am {move || x().round()} , {move || y().round()}
} } diff --git a/examples/use_drop_zone/src/main.rs b/examples/use_drop_zone/src/main.rs index d70c460..52f31d4 100644 --- a/examples/use_drop_zone/src/main.rs +++ b/examples/use_drop_zone/src/main.rs @@ -22,45 +22,21 @@ fn Demo() -> impl IntoView { view! {
-

- Drop files into dropZone -

+

Drop files into dropZone

Drop me
-
- is_over_drop_zone: - -
-
- dropped: - -
+
is_over_drop_zone:
+
dropped:
- +
-

- Name: - {file.name()} -

-

- Size: - {file.size()} -

-

- Type: - {file.type_()} -

-

- Last modified: - {file.last_modified()} -

+

Name: {file.name()}

+

Size: {file.size()}

+

Type: {file.type_()}

+

Last modified: {file.last_modified()}

diff --git a/examples/use_element_size/.cargo/config.toml b/examples/use_element_size/.cargo/config.toml deleted file mode 100644 index 8467175..0000000 --- a/examples/use_element_size/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[build] -rustflags = ["--cfg=web_sys_unstable_apis"] diff --git a/examples/use_event_listener/src/main.rs b/examples/use_event_listener/src/main.rs index eb2b22d..76c2f62 100644 --- a/examples/use_event_listener/src/main.rs +++ b/examples/use_event_listener/src/main.rs @@ -2,11 +2,11 @@ use leptos::ev::{click, keydown}; use leptos::html::A; use leptos::logging::log; use leptos::*; -use leptos_use::use_event_listener; +use leptos_use::{use_event_listener, use_window}; #[component] fn Demo() -> impl IntoView { - let _ = use_event_listener(window(), keydown, |evt| { + let _ = use_event_listener(use_window(), keydown, |evt| { log!("window keydown: '{}'", evt.key()); }); diff --git a/examples/use_geolocation/src/main.rs b/examples/use_geolocation/src/main.rs index ea2187a..895aa7e 100644 --- a/examples/use_geolocation/src/main.rs +++ b/examples/use_geolocation/src/main.rs @@ -27,20 +27,22 @@ fn Demo() -> impl IntoView { heading: {:?}, speed: {:?}, }}"#, - coords.accuracy(), coords.latitude(), coords.longitude(), coords.altitude(), - coords.altitude_accuracy(), coords.heading(), coords.speed() + coords.accuracy(), + coords.latitude(), + coords.longitude(), + coords.altitude(), + coords.altitude_accuracy(), + coords.heading(), + coords.speed(), ) } else { "None".to_string() } }} , - located_at: - {located_at} - , + located_at: {located_at} , error: - {move || if let Some(error) = error() { error.message() } else { "None".to_string() }} - , + {move || if let Some(error) = error() { error.message() } else { "None".to_string() }} , diff --git a/examples/use_idle/src/main.rs b/examples/use_idle/src/main.rs index 3ad03cb..26fb5af 100644 --- a/examples/use_idle/src/main.rs +++ b/examples/use_idle/src/main.rs @@ -14,20 +14,11 @@ fn Demo() -> impl IntoView { view! { - For demonstration purpose, the idle timeout is set to - - 5s - + For demonstration purpose, the idle timeout is set to 5s in this demo (default 1min). -
- Idle: - -
-
- Inactive: - {idled_for} s -
+
Idle:
+
Inactive: {idled_for} s
} } diff --git a/examples/use_infinite_scroll/Cargo.toml b/examples/use_infinite_scroll/Cargo.toml new file mode 100644 index 0000000..93b5074 --- /dev/null +++ b/examples/use_infinite_scroll/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "use_infinite_scroll" +version = "0.1.0" +edition = "2021" + +[dependencies] +leptos = { version = "0.5", features = ["nightly", "csr"] } +console_error_panic_hook = "0.1" +console_log = "1" +log = "0.4" +leptos-use = { path = "../..", features = ["docs"] } +web-sys = "0.3" + +[dev-dependencies] +wasm-bindgen = "0.2" +wasm-bindgen-test = "0.3.0" diff --git a/examples/use_infinite_scroll/README.md b/examples/use_infinite_scroll/README.md new file mode 100644 index 0000000..48fe6ea --- /dev/null +++ b/examples/use_infinite_scroll/README.md @@ -0,0 +1,23 @@ +A simple example for `use_infinite_scroll`. + +If you don't have it installed already, install [Trunk](https://trunkrs.dev/) and [Tailwind](https://tailwindcss.com/docs/installation) +as well as the nightly toolchain for Rust and the wasm32-unknown-unknown target: + +```bash +cargo install trunk +npm install -D tailwindcss @tailwindcss/forms +rustup toolchain install nightly +rustup target add wasm32-unknown-unknown +``` + +Then, open two terminals. In the first one, run: + +``` +npx tailwindcss -i ./input.css -o ./style/output.css --watch +``` + +In the second one, run: + +```bash +trunk serve --open +``` \ No newline at end of file diff --git a/examples/use_infinite_scroll/Trunk.toml b/examples/use_infinite_scroll/Trunk.toml new file mode 100644 index 0000000..3e4be08 --- /dev/null +++ b/examples/use_infinite_scroll/Trunk.toml @@ -0,0 +1,2 @@ +[build] +public_url = "/demo/" \ No newline at end of file diff --git a/examples/use_infinite_scroll/index.html b/examples/use_infinite_scroll/index.html new file mode 100644 index 0000000..ae249a6 --- /dev/null +++ b/examples/use_infinite_scroll/index.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/examples/use_infinite_scroll/input.css b/examples/use_infinite_scroll/input.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/examples/use_infinite_scroll/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/examples/use_infinite_scroll/rust-toolchain.toml b/examples/use_infinite_scroll/rust-toolchain.toml new file mode 100644 index 0000000..271800c --- /dev/null +++ b/examples/use_infinite_scroll/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" \ No newline at end of file diff --git a/examples/use_infinite_scroll/src/main.rs b/examples/use_infinite_scroll/src/main.rs new file mode 100644 index 0000000..57121ca --- /dev/null +++ b/examples/use_infinite_scroll/src/main.rs @@ -0,0 +1,40 @@ +use leptos::html::Div; +use leptos::*; +use leptos_use::docs::demo_or_body; +use leptos_use::{use_infinite_scroll_with_options, UseInfiniteScrollOptions}; + +#[component] +fn Demo() -> impl IntoView { + let el = create_node_ref::
(); + + let (data, set_data) = create_signal(vec![1, 2, 3, 4, 5, 6]); + + let _ = use_infinite_scroll_with_options( + el, + move |_| async move { + let len = data.with_untracked(|d| d.len()); + set_data.update(|data| *data = (1..len + 6).collect()); + }, + UseInfiniteScrollOptions::default().distance(10.0), + ); + + view! { +
+ +
{item}
+
+
+ } +} + +fn main() { + _ = console_log::init_with_level(log::Level::Debug); + console_error_panic_hook::set_once(); + + mount_to(demo_or_body(), || { + view! { } + }) +} diff --git a/examples/use_infinite_scroll/style/output.css b/examples/use_infinite_scroll/style/output.css new file mode 100644 index 0000000..33e91d6 --- /dev/null +++ b/examples/use_infinite_scroll/style/output.css @@ -0,0 +1,338 @@ +[type='text'],input:where(:not([type])),[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: #fff; + border-color: #6b7280; + border-width: 1px; + border-radius: 0px; + padding-top: 0.5rem; + padding-right: 0.75rem; + padding-bottom: 0.5rem; + padding-left: 0.75rem; + font-size: 1rem; + line-height: 1.5rem; + --tw-shadow: 0 0 #0000; +} + +[type='text']:focus, input:where(:not([type])):focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: #2563eb; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + border-color: #2563eb; +} + +input::-moz-placeholder, textarea::-moz-placeholder { + color: #6b7280; + opacity: 1; +} + +input::placeholder,textarea::placeholder { + color: #6b7280; + opacity: 1; +} + +::-webkit-datetime-edit-fields-wrapper { + padding: 0; +} + +::-webkit-date-and-time-value { + min-height: 1.5em; + text-align: inherit; +} + +::-webkit-datetime-edit { + display: inline-flex; +} + +::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field { + padding-top: 0; + padding-bottom: 0; +} + +select { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); + background-position: right 0.5rem center; + background-repeat: no-repeat; + background-size: 1.5em 1.5em; + padding-right: 2.5rem; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; +} + +[multiple],[size]:where(select:not([size="1"])) { + background-image: initial; + background-position: initial; + background-repeat: unset; + background-size: initial; + padding-right: 0.75rem; + -webkit-print-color-adjust: unset; + print-color-adjust: unset; +} + +[type='checkbox'],[type='radio'] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + padding: 0; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + display: inline-block; + vertical-align: middle; + background-origin: border-box; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + flex-shrink: 0; + height: 1rem; + width: 1rem; + color: #2563eb; + background-color: #fff; + border-color: #6b7280; + border-width: 1px; + --tw-shadow: 0 0 #0000; +} + +[type='checkbox'] { + border-radius: 0px; +} + +[type='radio'] { + border-radius: 100%; +} + +[type='checkbox']:focus,[type='radio']:focus { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); + --tw-ring-offset-width: 2px; + --tw-ring-offset-color: #fff; + --tw-ring-color: #2563eb; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); +} + +[type='checkbox']:checked,[type='radio']:checked { + border-color: transparent; + background-color: currentColor; + background-size: 100% 100%; + background-position: center; + background-repeat: no-repeat; +} + +[type='checkbox']:checked { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); +} + +[type='radio']:checked { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); +} + +[type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus { + border-color: transparent; + background-color: currentColor; +} + +[type='checkbox']:indeterminate { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e"); + border-color: transparent; + background-color: currentColor; + background-size: 100% 100%; + background-position: center; + background-repeat: no-repeat; +} + +[type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus { + border-color: transparent; + background-color: currentColor; +} + +[type='file'] { + background: unset; + border-color: inherit; + border-width: 0; + border-radius: 0; + padding: 0; + font-size: unset; + line-height: inherit; +} + +[type='file']:focus { + outline: 1px solid ButtonText; + outline: 1px auto -webkit-focus-ring-color; +} + +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +.static { + position: static; +} + +.m-auto { + margin: auto; +} + +.flex { + display: flex; +} + +.h-\[300px\] { + height: 300px; +} + +.w-\[300px\] { + width: 300px; +} + +.flex-col { + flex-direction: column; +} + +.gap-2 { + gap: 0.5rem; +} + +.overflow-y-scroll { + overflow-y: scroll; +} + +.rounded { + border-radius: 0.25rem; +} + +.bg-gray-500\/5 { + background-color: rgb(107 114 128 / 0.05); +} + +.p-3 { + padding: 0.75rem; +} + +.p-4 { + padding: 1rem; +} + +.text-\[--brand-color\] { + color: var(--brand-color); +} + +.text-green-600 { + --tw-text-opacity: 1; + color: rgb(22 163 74 / var(--tw-text-opacity)); +} + +.opacity-75 { + opacity: 0.75; +} + +@media (prefers-color-scheme: dark) { + .dark\:text-green-500 { + --tw-text-opacity: 1; + color: rgb(34 197 94 / var(--tw-text-opacity)); + } +} \ No newline at end of file diff --git a/examples/use_infinite_scroll/tailwind.config.js b/examples/use_infinite_scroll/tailwind.config.js new file mode 100644 index 0000000..bc09f5e --- /dev/null +++ b/examples/use_infinite_scroll/tailwind.config.js @@ -0,0 +1,15 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: { + files: ["*.html", "./src/**/*.rs", "../../src/docs/**/*.rs"], + }, + theme: { + extend: {}, + }, + corePlugins: { + preflight: false, + }, + plugins: [ + require('@tailwindcss/forms'), + ], +} \ No newline at end of file diff --git a/examples/use_mouse/src/main.rs b/examples/use_mouse/src/main.rs index e96ac32..7269145 100644 --- a/examples/use_mouse/src/main.rs +++ b/examples/use_mouse/src/main.rs @@ -42,8 +42,10 @@ fn Demo() -> impl IntoView { r#" x: {} y: {} source_type: {:?} -"#, mouse_default.x.get(), - mouse_default.y.get(), mouse_default.source_type.get() +"#, + mouse_default.x.get(), + mouse_default.y.get(), + mouse_default.source_type.get(), ) }} @@ -56,8 +58,10 @@ fn Demo() -> impl IntoView { r#" x: {} y: {} source_type: {:?} -"#, mouse_with_extractor.x - .get(), mouse_with_extractor.y.get(), mouse_with_extractor.source_type.get() +"#, + mouse_with_extractor.x.get(), + mouse_with_extractor.y.get(), + mouse_with_extractor.source_type.get(), ) }} diff --git a/examples/use_raf_fn/src/main.rs b/examples/use_raf_fn/src/main.rs index 4e794a2..5c8b14f 100644 --- a/examples/use_raf_fn/src/main.rs +++ b/examples/use_raf_fn/src/main.rs @@ -15,10 +15,7 @@ fn Demo() -> impl IntoView { }); view! { -
- Count: - {count} -
+
Count: {count}
diff --git a/examples/use_service_worker/Cargo.toml b/examples/use_service_worker/Cargo.toml new file mode 100644 index 0000000..878fa9a --- /dev/null +++ b/examples/use_service_worker/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "use_service_worker" +version = "0.1.0" +edition = "2021" + +[dependencies] +leptos = { version = "0.5", features = ["nightly", "csr"] } +console_error_panic_hook = "0.1" +console_log = "1" +log = "0.4" +leptos-use = { path = "../..", features = ["docs"] } +web-sys = "0.3" + +[dev-dependencies] +wasm-bindgen = "0.2" +wasm-bindgen-test = "0.3.0" diff --git a/examples/use_service_worker/README.md b/examples/use_service_worker/README.md new file mode 100644 index 0000000..a4e333d --- /dev/null +++ b/examples/use_service_worker/README.md @@ -0,0 +1,23 @@ +A simple example for `use_service_worker`. + +If you don't have it installed already, install [Trunk](https://trunkrs.dev/) and [Tailwind](https://tailwindcss.com/docs/installation) +as well as the nightly toolchain for Rust and the wasm32-unknown-unknown target: + +```bash +cargo install trunk +npm install -D tailwindcss @tailwindcss/forms +rustup toolchain install nightly +rustup target add wasm32-unknown-unknown +``` + +Then, open two terminals. In the first one, run: + +``` +npx tailwindcss -i ./input.css -o ./style/output.css --watch +``` + +In the second one, run: + +```bash +trunk serve --open +``` \ No newline at end of file diff --git a/examples/use_service_worker/Trunk.toml b/examples/use_service_worker/Trunk.toml new file mode 100644 index 0000000..341a2b1 --- /dev/null +++ b/examples/use_service_worker/Trunk.toml @@ -0,0 +1,7 @@ +[build] +public_url = "/demo/" + +[[hooks]] +stage = "post_build" +command = "bash" +command_arguments = ["post_build.sh"] diff --git a/examples/use_service_worker/index.html b/examples/use_service_worker/index.html new file mode 100644 index 0000000..c3733f9 --- /dev/null +++ b/examples/use_service_worker/index.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/examples/use_service_worker/input.css b/examples/use_service_worker/input.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/examples/use_service_worker/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/examples/use_service_worker/manifest.json b/examples/use_service_worker/manifest.json new file mode 100644 index 0000000..8b720cf --- /dev/null +++ b/examples/use_service_worker/manifest.json @@ -0,0 +1,54 @@ +{ + "name": "Demo", + "short_name": "Demo", + "icons": [ + { + "src": "./res/icon/maskable_icon_x48.png", + "sizes": "48x48", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "./res/icon/maskable_icon_x72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "./res/icon/maskable_icon_x96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "./res/icon/maskable_icon_x128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "./res/icon/maskable_icon_x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "./res/icon/maskable_icon_x384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "./res/icon/maskable_icon_x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "lang": "en-US", + "id": "/demo/", + "start_url": "/demo/", + "display": "standalone", + "background_color": "#e52320", + "theme_color": "#e52320" +} \ No newline at end of file diff --git a/examples/use_service_worker/post_build.sh b/examples/use_service_worker/post_build.sh new file mode 100755 index 0000000..e6ebf5a --- /dev/null +++ b/examples/use_service_worker/post_build.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -e + +appName="use_service_worker" +stylePrefix="output" +styleFormat="css" + +# Extract build version +indexJsFile=$(find ./dist/.stage -iname "${appName}-*.js") +echo "Extracting build version from file: ${indexJsFile}" +regex="(.*)${appName}-(.*).js" +_src="${indexJsFile}" +while [[ "${_src}" =~ ${regex} ]]; do + buildVersion="${BASH_REMATCH[2]}" + _i=${#BASH_REMATCH} + _src=${_src:_i} +done +if [ -z "${buildVersion}" ]; then + echo "Could not determine build version!" + exit 1 +fi +echo "Build-Version is: ${buildVersion}" + +# Replace placeholder in service-worker.js +serviceWorkerJsFile=$(find ./dist/.stage -iname "service-worker.js") +echo "Replacing {{buildVersion}} placeholder in: ${serviceWorkerJsFile}" +sed "s/{{buildVersion}}/${buildVersion}/g" "${serviceWorkerJsFile}" > "${serviceWorkerJsFile}.modified" +mv -f "${serviceWorkerJsFile}.modified" "${serviceWorkerJsFile}" + +# Replace placeholder in index.html +indexHtmlFile=$(find ./dist/.stage -iname "index.html") +echo "Replacing {{buildVersion}} placeholder in: ${indexHtmlFile}" +sed "s/{{buildVersion}}/${buildVersion}/g" "${indexHtmlFile}" > "${indexHtmlFile}.modified" +mv -f "${indexHtmlFile}.modified" "${indexHtmlFile}" + +# Extract CSS build version +indexJsFile=$(find ./dist/.stage -iname "${stylePrefix}-*.${styleFormat}") +echo "Extracting style build version from file: ${indexJsFile}" +regex="(.*)${stylePrefix}-(.*).${styleFormat}" +_src="${indexJsFile}" +while [[ "${_src}" =~ ${regex} ]]; do + cssBuildVersion="${BASH_REMATCH[2]}" + _i=${#BASH_REMATCH} + _src=${_src:_i} +done +if [ -z "${cssBuildVersion}" ]; then + echo "Could not determine style build version!" + exit 1 +fi +echo "CSS Build-Version is: ${cssBuildVersion}" + +# Replace placeholder in service-worker.js +serviceWorkerJsFile=$(find ./dist/.stage -iname "service-worker.js") +echo "Replacing {{cssBuildVersion}} placeholder in: ${serviceWorkerJsFile}" +sed "s/{{cssBuildVersion}}/${cssBuildVersion}/g" "${serviceWorkerJsFile}" > "${serviceWorkerJsFile}.modified" +mv -f "${serviceWorkerJsFile}.modified" "${serviceWorkerJsFile}" diff --git a/examples/use_service_worker/res/icon/maskable_icon_x128.png b/examples/use_service_worker/res/icon/maskable_icon_x128.png new file mode 100644 index 0000000..f730574 Binary files /dev/null and b/examples/use_service_worker/res/icon/maskable_icon_x128.png differ diff --git a/examples/use_service_worker/res/icon/maskable_icon_x192.png b/examples/use_service_worker/res/icon/maskable_icon_x192.png new file mode 100644 index 0000000..6e308ac Binary files /dev/null and b/examples/use_service_worker/res/icon/maskable_icon_x192.png differ diff --git a/examples/use_service_worker/res/icon/maskable_icon_x384.png b/examples/use_service_worker/res/icon/maskable_icon_x384.png new file mode 100644 index 0000000..3041f52 Binary files /dev/null and b/examples/use_service_worker/res/icon/maskable_icon_x384.png differ diff --git a/examples/use_service_worker/res/icon/maskable_icon_x48.png b/examples/use_service_worker/res/icon/maskable_icon_x48.png new file mode 100644 index 0000000..50e8754 Binary files /dev/null and b/examples/use_service_worker/res/icon/maskable_icon_x48.png differ diff --git a/examples/use_service_worker/res/icon/maskable_icon_x512.png b/examples/use_service_worker/res/icon/maskable_icon_x512.png new file mode 100644 index 0000000..d1e37b9 Binary files /dev/null and b/examples/use_service_worker/res/icon/maskable_icon_x512.png differ diff --git a/examples/use_service_worker/res/icon/maskable_icon_x72.png b/examples/use_service_worker/res/icon/maskable_icon_x72.png new file mode 100644 index 0000000..4196897 Binary files /dev/null and b/examples/use_service_worker/res/icon/maskable_icon_x72.png differ diff --git a/examples/use_service_worker/res/icon/maskable_icon_x96.png b/examples/use_service_worker/res/icon/maskable_icon_x96.png new file mode 100644 index 0000000..32b9490 Binary files /dev/null and b/examples/use_service_worker/res/icon/maskable_icon_x96.png differ diff --git a/examples/use_service_worker/res/icon/pwa.png b/examples/use_service_worker/res/icon/pwa.png new file mode 100644 index 0000000..b5a31af Binary files /dev/null and b/examples/use_service_worker/res/icon/pwa.png differ diff --git a/examples/use_service_worker/res/icon/pwa.svg b/examples/use_service_worker/res/icon/pwa.svg new file mode 100644 index 0000000..55264a9 --- /dev/null +++ b/examples/use_service_worker/res/icon/pwa.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/examples/use_service_worker/rust-toolchain.toml b/examples/use_service_worker/rust-toolchain.toml new file mode 100644 index 0000000..271800c --- /dev/null +++ b/examples/use_service_worker/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" \ No newline at end of file diff --git a/examples/use_service_worker/service-worker.js b/examples/use_service_worker/service-worker.js new file mode 100644 index 0000000..511a41b --- /dev/null +++ b/examples/use_service_worker/service-worker.js @@ -0,0 +1,82 @@ +var buildVersion = "{{buildVersion}}" +var cssBuildVersion = "{{cssBuildVersion}}" +var cacheName = "demo"; +var filesToCache = [ + './', + './index.html', + './manifest.json', + './use_service_worker-' + buildVersion + '_bg.wasm', + './use_service_worker-' + buildVersion + '.js', + './output-' + cssBuildVersion + '.css', + './res/icon/maskable_icon_x48.png', + './res/icon/maskable_icon_x72.png', + './res/icon/maskable_icon_x96.png', + './res/icon/maskable_icon_x128.png', + './res/icon/maskable_icon_x192.png', + './res/icon/maskable_icon_x384.png', + './res/icon/maskable_icon_x512.png', + + // TODO: Add files you want the SW to cache. Rename entries to match your build output! +]; + +/* Start the service worker and cache all of the app's content */ +self.addEventListener('install', function (event) { + console.log("Installing service-worker for build", buildVersion); + const preCache = async () => { + get_cache().then(function (cache) { + // We clear the whole cache, as we do not know which resources were updated! + cache.keys().then(function (requests) { + for (let request of requests) { + cache.delete(request); + } + }); + cache.addAll(filesToCache.map(url => new Request(url, { credentials: 'same-origin' }))); + }) + }; + event.waitUntil(preCache); +}); + +self.addEventListener('message', function (messageEvent) { + if (messageEvent.data === "skipWaiting") { + console.log("Service-worker received skipWaiting event", buildVersion); + self.skipWaiting(); + } +}); + +self.addEventListener('fetch', function (e) { + e.respondWith(cache_then_network(e.request)); +}); + +async function get_cache() { + return caches.open(cacheName); +} + +async function cache_then_network(request) { + const cache = await get_cache(); + return cache.match(request).then( + (cache_response) => { + if (!cache_response) { + return fetch_from_network(request, cache); + } else { + return cache_response; + } + }, + (reason) => { + return fetch_from_network(request, cache); + } + ); +} + +function fetch_from_network(request, cache) { + return fetch(request).then( + (net_response) => { + return net_response; + }, + (reason) => { + console.error("Network fetch rejected. Falling back to ./index.html. Reason: ", reason); + return cache.match("./index.html").then(function (cache_root_response) { + return cache_root_response; + }); + } + ) +} diff --git a/examples/use_service_worker/src/main.rs b/examples/use_service_worker/src/main.rs new file mode 100644 index 0000000..93dc1fa --- /dev/null +++ b/examples/use_service_worker/src/main.rs @@ -0,0 +1,59 @@ +use leptos::*; +use leptos_use::docs::{demo_or_body, BooleanDisplay}; +use leptos_use::{use_document, use_service_worker, UseServiceWorkerReturn}; +use web_sys::HtmlMetaElement; + +#[component] +fn Demo() -> impl IntoView { + let build = load_meta_element("version") + .map(|meta| meta.content()) + .expect("'version' meta element"); + + let UseServiceWorkerReturn { + registration, + installing, + waiting, + active, + skip_waiting, + .. + } = use_service_worker(); + + view! { +

"Current build: " {build}

+ +
+ +

"registration: " {move || format!("{:#?}", registration())}

+

"installing: "

+

"waiting: "

+

"active: "

+ +
+ + + } +} + +fn main() { + _ = console_log::init_with_level(log::Level::Debug); + console_error_panic_hook::set_once(); + + mount_to(demo_or_body(), || { + view! { } + }) +} + +fn load_meta_element(name: &str) -> Result { + use wasm_bindgen::JsCast; + if let Some(document) = &*use_document() { + document + .query_selector(format!("meta[name=\"{name}\"]").as_str()) + .ok() + .flatten() + .ok_or_else(|| format!("Unable to find meta element with name '{name}'."))? + .dyn_into::() + .map_err(|err| format!("Unable to cast element to HtmlMetaElement. Err: '{err:?}'.")) + } else { + Err("Unable to find document.".into()) + } +} diff --git a/examples/use_service_worker/style/output.css b/examples/use_service_worker/style/output.css new file mode 100644 index 0000000..1c83da6 --- /dev/null +++ b/examples/use_service_worker/style/output.css @@ -0,0 +1,294 @@ +[type='text'],input:where(:not([type])),[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: #fff; + border-color: #6b7280; + border-width: 1px; + border-radius: 0px; + padding-top: 0.5rem; + padding-right: 0.75rem; + padding-bottom: 0.5rem; + padding-left: 0.75rem; + font-size: 1rem; + line-height: 1.5rem; + --tw-shadow: 0 0 #0000; +} + +[type='text']:focus, input:where(:not([type])):focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: #2563eb; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + border-color: #2563eb; +} + +input::-moz-placeholder, textarea::-moz-placeholder { + color: #6b7280; + opacity: 1; +} + +input::placeholder,textarea::placeholder { + color: #6b7280; + opacity: 1; +} + +::-webkit-datetime-edit-fields-wrapper { + padding: 0; +} + +::-webkit-date-and-time-value { + min-height: 1.5em; + text-align: inherit; +} + +::-webkit-datetime-edit { + display: inline-flex; +} + +::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field { + padding-top: 0; + padding-bottom: 0; +} + +select { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); + background-position: right 0.5rem center; + background-repeat: no-repeat; + background-size: 1.5em 1.5em; + padding-right: 2.5rem; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; +} + +[multiple],[size]:where(select:not([size="1"])) { + background-image: initial; + background-position: initial; + background-repeat: unset; + background-size: initial; + padding-right: 0.75rem; + -webkit-print-color-adjust: unset; + print-color-adjust: unset; +} + +[type='checkbox'],[type='radio'] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + padding: 0; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + display: inline-block; + vertical-align: middle; + background-origin: border-box; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + flex-shrink: 0; + height: 1rem; + width: 1rem; + color: #2563eb; + background-color: #fff; + border-color: #6b7280; + border-width: 1px; + --tw-shadow: 0 0 #0000; +} + +[type='checkbox'] { + border-radius: 0px; +} + +[type='radio'] { + border-radius: 100%; +} + +[type='checkbox']:focus,[type='radio']:focus { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); + --tw-ring-offset-width: 2px; + --tw-ring-offset-color: #fff; + --tw-ring-color: #2563eb; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); +} + +[type='checkbox']:checked,[type='radio']:checked { + border-color: transparent; + background-color: currentColor; + background-size: 100% 100%; + background-position: center; + background-repeat: no-repeat; +} + +[type='checkbox']:checked { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); +} + +[type='radio']:checked { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); +} + +[type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus { + border-color: transparent; + background-color: currentColor; +} + +[type='checkbox']:indeterminate { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e"); + border-color: transparent; + background-color: currentColor; + background-size: 100% 100%; + background-position: center; + background-repeat: no-repeat; +} + +[type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus { + border-color: transparent; + background-color: currentColor; +} + +[type='file'] { + background: unset; + border-color: inherit; + border-width: 0; + border-radius: 0; + padding: 0; + font-size: unset; + line-height: inherit; +} + +[type='file']:focus { + outline: 1px solid ButtonText; + outline: 1px auto -webkit-focus-ring-color; +} + +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +.static { + position: static; +} + +.text-\[--brand-color\] { + color: var(--brand-color); +} + +.text-green-600 { + --tw-text-opacity: 1; + color: rgb(22 163 74 / var(--tw-text-opacity)); +} + +.opacity-75 { + opacity: 0.75; +} + +@media (prefers-color-scheme: dark) { + .dark\:text-green-500 { + --tw-text-opacity: 1; + color: rgb(34 197 94 / var(--tw-text-opacity)); + } +} \ No newline at end of file diff --git a/examples/use_service_worker/tailwind.config.js b/examples/use_service_worker/tailwind.config.js new file mode 100644 index 0000000..bc09f5e --- /dev/null +++ b/examples/use_service_worker/tailwind.config.js @@ -0,0 +1,15 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: { + files: ["*.html", "./src/**/*.rs", "../../src/docs/**/*.rs"], + }, + theme: { + extend: {}, + }, + corePlugins: { + preflight: false, + }, + plugins: [ + require('@tailwindcss/forms'), + ], +} \ No newline at end of file diff --git a/examples/use_sorted/Cargo.toml b/examples/use_sorted/Cargo.toml new file mode 100644 index 0000000..6b5a699 --- /dev/null +++ b/examples/use_sorted/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "use_sorted" +version = "0.1.0" +edition = "2021" + +[dependencies] +leptos = { version = "0.5", features = ["nightly", "csr"] } +console_error_panic_hook = "0.1" +console_log = "1" +log = "0.4" +leptos-use = { path = "../..", features = ["docs"] } +web-sys = "0.3" + +[dev-dependencies] +wasm-bindgen = "0.2" +wasm-bindgen-test = "0.3.0" diff --git a/examples/use_sorted/README.md b/examples/use_sorted/README.md new file mode 100644 index 0000000..25443d9 --- /dev/null +++ b/examples/use_sorted/README.md @@ -0,0 +1,23 @@ +A simple example for `use_sorted`. + +If you don't have it installed already, install [Trunk](https://trunkrs.dev/) and [Tailwind](https://tailwindcss.com/docs/installation) +as well as the nightly toolchain for Rust and the wasm32-unknown-unknown target: + +```bash +cargo install trunk +npm install -D tailwindcss @tailwindcss/forms +rustup toolchain install nightly +rustup target add wasm32-unknown-unknown +``` + +Then, open two terminals. In the first one, run: + +``` +npx tailwindcss -i ./input.css -o ./style/output.css --watch +``` + +In the second one, run: + +```bash +trunk serve --open +``` \ No newline at end of file diff --git a/examples/use_sorted/Trunk.toml b/examples/use_sorted/Trunk.toml new file mode 100644 index 0000000..3e4be08 --- /dev/null +++ b/examples/use_sorted/Trunk.toml @@ -0,0 +1,2 @@ +[build] +public_url = "/demo/" \ No newline at end of file diff --git a/examples/use_sorted/index.html b/examples/use_sorted/index.html new file mode 100644 index 0000000..ae249a6 --- /dev/null +++ b/examples/use_sorted/index.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/examples/use_sorted/input.css b/examples/use_sorted/input.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/examples/use_sorted/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/examples/use_sorted/rust-toolchain.toml b/examples/use_sorted/rust-toolchain.toml new file mode 100644 index 0000000..271800c --- /dev/null +++ b/examples/use_sorted/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" \ No newline at end of file diff --git a/examples/use_sorted/src/main.rs b/examples/use_sorted/src/main.rs new file mode 100644 index 0000000..4ffb11d --- /dev/null +++ b/examples/use_sorted/src/main.rs @@ -0,0 +1,44 @@ +use leptos::*; +use leptos_use::docs::demo_or_body; +use leptos_use::use_sorted; + +fn string_list(list: &[i32]) -> String { + list.into_iter() + .map(i32::to_string) + .collect::>() + .join(",") +} + +#[component] +fn Demo() -> impl IntoView { + let (list, set_list) = create_signal::>(vec![4, 2, 67, 34, 76, 22, 2, 4, 65, 23]); + + let sorted: Signal> = use_sorted(list); + + let on_input = move |evt| { + set_list.update(|list| { + *list = event_target_value(&evt) + .split(",") + .map(|n| n.parse::().unwrap_or(0)) + .collect::>() + }); + }; + + let input_text = move || string_list(&list()); + let sorted_text = move || string_list(&sorted()); + + view! { +
Input:
+ +

Output: {sorted_text}

+ } +} + +fn main() { + _ = console_log::init_with_level(log::Level::Debug); + console_error_panic_hook::set_once(); + + mount_to(demo_or_body(), || { + view! { } + }) +} diff --git a/examples/use_sorted/style/output.css b/examples/use_sorted/style/output.css new file mode 100644 index 0000000..ab5191f --- /dev/null +++ b/examples/use_sorted/style/output.css @@ -0,0 +1,289 @@ +[type='text'],[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: #fff; + border-color: #6b7280; + border-width: 1px; + border-radius: 0px; + padding-top: 0.5rem; + padding-right: 0.75rem; + padding-bottom: 0.5rem; + padding-left: 0.75rem; + font-size: 1rem; + line-height: 1.5rem; + --tw-shadow: 0 0 #0000; +} + +[type='text']:focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: #2563eb; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + border-color: #2563eb; +} + +input::-moz-placeholder, textarea::-moz-placeholder { + color: #6b7280; + opacity: 1; +} + +input::placeholder,textarea::placeholder { + color: #6b7280; + opacity: 1; +} + +::-webkit-datetime-edit-fields-wrapper { + padding: 0; +} + +::-webkit-date-and-time-value { + min-height: 1.5em; +} + +::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field { + padding-top: 0; + padding-bottom: 0; +} + +select { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); + background-position: right 0.5rem center; + background-repeat: no-repeat; + background-size: 1.5em 1.5em; + padding-right: 2.5rem; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; +} + +[multiple] { + background-image: initial; + background-position: initial; + background-repeat: unset; + background-size: initial; + padding-right: 0.75rem; + -webkit-print-color-adjust: unset; + print-color-adjust: unset; +} + +[type='checkbox'],[type='radio'] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + padding: 0; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + display: inline-block; + vertical-align: middle; + background-origin: border-box; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + flex-shrink: 0; + height: 1rem; + width: 1rem; + color: #2563eb; + background-color: #fff; + border-color: #6b7280; + border-width: 1px; + --tw-shadow: 0 0 #0000; +} + +[type='checkbox'] { + border-radius: 0px; +} + +[type='radio'] { + border-radius: 100%; +} + +[type='checkbox']:focus,[type='radio']:focus { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); + --tw-ring-offset-width: 2px; + --tw-ring-offset-color: #fff; + --tw-ring-color: #2563eb; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); +} + +[type='checkbox']:checked,[type='radio']:checked { + border-color: transparent; + background-color: currentColor; + background-size: 100% 100%; + background-position: center; + background-repeat: no-repeat; +} + +[type='checkbox']:checked { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); +} + +[type='radio']:checked { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); +} + +[type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus { + border-color: transparent; + background-color: currentColor; +} + +[type='checkbox']:indeterminate { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e"); + border-color: transparent; + background-color: currentColor; + background-size: 100% 100%; + background-position: center; + background-repeat: no-repeat; +} + +[type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus { + border-color: transparent; + background-color: currentColor; +} + +[type='file'] { + background: unset; + border-color: inherit; + border-width: 0; + border-radius: 0; + padding: 0; + font-size: unset; + line-height: inherit; +} + +[type='file']:focus { + outline: 1px solid ButtonText; + outline: 1px auto -webkit-focus-ring-color; +} + +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +.block { + display: block; +} + +.text-\[--brand-color\] { + color: var(--brand-color); +} + +.text-green-600 { + --tw-text-opacity: 1; + color: rgb(22 163 74 / var(--tw-text-opacity)); +} + +.opacity-75 { + opacity: 0.75; +} + +@media (prefers-color-scheme: dark) { + .dark\:text-green-500 { + --tw-text-opacity: 1; + color: rgb(34 197 94 / var(--tw-text-opacity)); + } +} \ No newline at end of file diff --git a/examples/use_sorted/tailwind.config.js b/examples/use_sorted/tailwind.config.js new file mode 100644 index 0000000..bc09f5e --- /dev/null +++ b/examples/use_sorted/tailwind.config.js @@ -0,0 +1,15 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: { + files: ["*.html", "./src/**/*.rs", "../../src/docs/**/*.rs"], + }, + theme: { + extend: {}, + }, + corePlugins: { + preflight: false, + }, + plugins: [ + require('@tailwindcss/forms'), + ], +} \ No newline at end of file diff --git a/examples/use_storage/Cargo.toml b/examples/use_storage/Cargo.toml index 09446d5..6686fe8 100644 --- a/examples/use_storage/Cargo.toml +++ b/examples/use_storage/Cargo.toml @@ -8,7 +8,7 @@ leptos = { version = "0.5", features = ["nightly", "csr"] } console_error_panic_hook = "0.1" console_log = "1" log = "0.4" -leptos-use = { path = "../..", features = ["docs", "storage"] } +leptos-use = { path = "../..", features = ["docs", "prost", "serde"] } web-sys = "0.3" serde = "1.0.163" diff --git a/examples/use_storage/src/main.rs b/examples/use_storage/src/main.rs index 7f5442f..c1a98fa 100644 --- a/examples/use_storage/src/main.rs +++ b/examples/use_storage/src/main.rs @@ -1,28 +1,31 @@ use leptos::*; use leptos_use::docs::{demo_or_body, Note}; -use leptos_use::storage::use_storage; +use leptos_use::storage::{use_local_storage, JsonCodec}; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] pub struct BananaState { pub name: String, - pub color: String, - pub size: String, + pub wearing: String, + pub descending: String, pub count: u32, } +impl Default for BananaState { + fn default() -> Self { + Self { + name: "Bananas".to_string(), + wearing: "pyjamas".to_string(), + descending: "stairs".to_string(), + count: 2, + } + } +} + #[component] fn Demo() -> impl IntoView { - let the_default = BananaState { - name: "Banana".to_string(), - color: "Yellow".to_string(), - size: "Medium".to_string(), - count: 0, - }; - - let (state, set_state, _) = use_storage("banana-state", the_default.clone()); - - let (state2, ..) = use_storage("banana-state", the_default.clone()); + let (state, set_state, reset) = use_local_storage::("banana-state"); + let (state2, _, _) = use_local_storage::("banana-state"); view! { impl IntoView { /> impl IntoView { step="1" max="1000" /> +

"Second " @@ -67,7 +71,9 @@ fn Demo() -> impl IntoView {

{move || format!("{:#?}", state2.get())}
- "The values are persistent. When you reload the page the values will be the same." + "The values are persistent. When you reload the page or " + "open a second window" + ", the values will be the same." } } diff --git a/examples/use_timestamp/src/main.rs b/examples/use_timestamp/src/main.rs index b1e79ad..f67a8eb 100644 --- a/examples/use_timestamp/src/main.rs +++ b/examples/use_timestamp/src/main.rs @@ -6,12 +6,7 @@ use leptos_use::use_timestamp; fn Demo() -> impl IntoView { let timestamp = use_timestamp(); - view! { -
- Timestamp: - {timestamp} -
- } + view! {
Timestamp: {timestamp}
} } fn main() { diff --git a/examples/use_web_notification/Cargo.toml b/examples/use_web_notification/Cargo.toml new file mode 100644 index 0000000..26ee6c9 --- /dev/null +++ b/examples/use_web_notification/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "use_web_notification" +version = "0.1.0" +edition = "2021" + +[dependencies] +leptos = { version = "0.5", features = ["nightly", "csr"] } +console_error_panic_hook = "0.1" +console_log = "1" +log = "0.4" +leptos-use = { path = "../..", features = ["docs"] } +web-sys = "0.3" + +[dev-dependencies] +wasm-bindgen = "0.2" +wasm-bindgen-test = "0.3.0" diff --git a/examples/use_web_notification/README.md b/examples/use_web_notification/README.md new file mode 100644 index 0000000..aa9d332 --- /dev/null +++ b/examples/use_web_notification/README.md @@ -0,0 +1,23 @@ +A simple example for `use_web_notification`. + +If you don't have it installed already, install [Trunk](https://trunkrs.dev/) and [Tailwind](https://tailwindcss.com/docs/installation) +as well as the nightly toolchain for Rust and the wasm32-unknown-unknown target: + +```bash +cargo install trunk +npm install -D tailwindcss @tailwindcss/forms +rustup toolchain install nightly +rustup target add wasm32-unknown-unknown +``` + +Then, open two terminals. In the first one, run: + +``` +npx tailwindcss -i ./input.css -o ./style/output.css --watch +``` + +In the second one, run: + +```bash +trunk serve --open +``` \ No newline at end of file diff --git a/examples/use_web_notification/Trunk.toml b/examples/use_web_notification/Trunk.toml new file mode 100644 index 0000000..3e4be08 --- /dev/null +++ b/examples/use_web_notification/Trunk.toml @@ -0,0 +1,2 @@ +[build] +public_url = "/demo/" \ No newline at end of file diff --git a/examples/use_web_notification/index.html b/examples/use_web_notification/index.html new file mode 100644 index 0000000..ae249a6 --- /dev/null +++ b/examples/use_web_notification/index.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/examples/use_web_notification/input.css b/examples/use_web_notification/input.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/examples/use_web_notification/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/examples/use_web_notification/rust-toolchain.toml b/examples/use_web_notification/rust-toolchain.toml new file mode 100644 index 0000000..271800c --- /dev/null +++ b/examples/use_web_notification/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" \ No newline at end of file diff --git a/examples/use_web_notification/src/main.rs b/examples/use_web_notification/src/main.rs new file mode 100644 index 0000000..bc717bb --- /dev/null +++ b/examples/use_web_notification/src/main.rs @@ -0,0 +1,52 @@ +use leptos::*; +use leptos_use::docs::{demo_or_body, BooleanDisplay}; +use leptos_use::{ + use_web_notification_with_options, NotificationDirection, ShowOptions, + UseWebNotificationOptions, UseWebNotificationReturn, +}; + +#[component] +fn Demo() -> impl IntoView { + let UseWebNotificationReturn { + is_supported, show, .. + } = use_web_notification_with_options( + UseWebNotificationOptions::default() + .title("Hello World from leptos-use") + .direction(NotificationDirection::Auto) + .language("en") + // .renotify(true) + .tag("test"), + ); + + let show = move || { + show(ShowOptions::default()); + }; + + view! { +
+

Supported:

+
+ + The Notification Web API is not supported in your browser.
} + } + > + + + + } +} + +fn main() { + _ = console_log::init_with_level(log::Level::Debug); + console_error_panic_hook::set_once(); + + mount_to(demo_or_body(), || { + view! { } + }) +} diff --git a/examples/use_web_notification/style/output.css b/examples/use_web_notification/style/output.css new file mode 100644 index 0000000..ab5191f --- /dev/null +++ b/examples/use_web_notification/style/output.css @@ -0,0 +1,289 @@ +[type='text'],[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: #fff; + border-color: #6b7280; + border-width: 1px; + border-radius: 0px; + padding-top: 0.5rem; + padding-right: 0.75rem; + padding-bottom: 0.5rem; + padding-left: 0.75rem; + font-size: 1rem; + line-height: 1.5rem; + --tw-shadow: 0 0 #0000; +} + +[type='text']:focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: #2563eb; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + border-color: #2563eb; +} + +input::-moz-placeholder, textarea::-moz-placeholder { + color: #6b7280; + opacity: 1; +} + +input::placeholder,textarea::placeholder { + color: #6b7280; + opacity: 1; +} + +::-webkit-datetime-edit-fields-wrapper { + padding: 0; +} + +::-webkit-date-and-time-value { + min-height: 1.5em; +} + +::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field { + padding-top: 0; + padding-bottom: 0; +} + +select { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); + background-position: right 0.5rem center; + background-repeat: no-repeat; + background-size: 1.5em 1.5em; + padding-right: 2.5rem; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; +} + +[multiple] { + background-image: initial; + background-position: initial; + background-repeat: unset; + background-size: initial; + padding-right: 0.75rem; + -webkit-print-color-adjust: unset; + print-color-adjust: unset; +} + +[type='checkbox'],[type='radio'] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + padding: 0; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + display: inline-block; + vertical-align: middle; + background-origin: border-box; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + flex-shrink: 0; + height: 1rem; + width: 1rem; + color: #2563eb; + background-color: #fff; + border-color: #6b7280; + border-width: 1px; + --tw-shadow: 0 0 #0000; +} + +[type='checkbox'] { + border-radius: 0px; +} + +[type='radio'] { + border-radius: 100%; +} + +[type='checkbox']:focus,[type='radio']:focus { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); + --tw-ring-offset-width: 2px; + --tw-ring-offset-color: #fff; + --tw-ring-color: #2563eb; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); +} + +[type='checkbox']:checked,[type='radio']:checked { + border-color: transparent; + background-color: currentColor; + background-size: 100% 100%; + background-position: center; + background-repeat: no-repeat; +} + +[type='checkbox']:checked { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); +} + +[type='radio']:checked { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); +} + +[type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus { + border-color: transparent; + background-color: currentColor; +} + +[type='checkbox']:indeterminate { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e"); + border-color: transparent; + background-color: currentColor; + background-size: 100% 100%; + background-position: center; + background-repeat: no-repeat; +} + +[type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus { + border-color: transparent; + background-color: currentColor; +} + +[type='file'] { + background: unset; + border-color: inherit; + border-width: 0; + border-radius: 0; + padding: 0; + font-size: unset; + line-height: inherit; +} + +[type='file']:focus { + outline: 1px solid ButtonText; + outline: 1px auto -webkit-focus-ring-color; +} + +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +.block { + display: block; +} + +.text-\[--brand-color\] { + color: var(--brand-color); +} + +.text-green-600 { + --tw-text-opacity: 1; + color: rgb(22 163 74 / var(--tw-text-opacity)); +} + +.opacity-75 { + opacity: 0.75; +} + +@media (prefers-color-scheme: dark) { + .dark\:text-green-500 { + --tw-text-opacity: 1; + color: rgb(34 197 94 / var(--tw-text-opacity)); + } +} \ No newline at end of file diff --git a/examples/use_web_notification/tailwind.config.js b/examples/use_web_notification/tailwind.config.js new file mode 100644 index 0000000..bc09f5e --- /dev/null +++ b/examples/use_web_notification/tailwind.config.js @@ -0,0 +1,15 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: { + files: ["*.html", "./src/**/*.rs", "../../src/docs/**/*.rs"], + }, + theme: { + extend: {}, + }, + corePlugins: { + preflight: false, + }, + plugins: [ + require('@tailwindcss/forms'), + ], +} \ No newline at end of file diff --git a/examples/use_webtransport_with_server/server/cert.crt b/examples/use_webtransport_with_server/server/cert.crt new file mode 100644 index 0000000..3d4b2c1 --- /dev/null +++ b/examples/use_webtransport_with_server/server/cert.crt @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIEEDCCAvigAwIBAgIUM7q41K5+wQF+tIUZVVjHRLVP8WYwDQYJKoZIhvcNAQEL +BQAwgZAxCzAJBgNVBAYTAkVTMREwDwYDVQQIDAhUZW5lcmlmZTETMBEGA1UEBwwK +R3JhbmFkaWxsYTETMBEGA1UECgwKU3lucGhvbnl0ZTEcMBoGA1UEAwwTUm9vdCBD +ZXJ0IEF1dGhvcml0eTEmMCQGCSqGSIb3DQEJARYXbWFjY2VzY2hAc3lucGhvbnl0 +ZS5jb20wHhcNMjMwOTE3MjI0NDExWhcNMjUxMjIwMjI0NDExWjCBgDELMAkGA1UE +BhMCRVMxETAPBgNVBAgMCFRlbmVyaWZlMRMwEQYDVQQHDApHcmFuYWRpbGxhMRMw +EQYDVQQKDApTeW5waG9ueXRlMQwwCgYDVQQDDANCbGExJjAkBgkqhkiG9w0BCQEW +F21hY2Nlc2NoQHN5bnBob255dGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAvDj30GptcuZcTnYZxSULMQqU1csp/54kRM7Emdpsf21es16rRoUO +xXfvxGnbGgWb6F2lAG8Gmv7YoliOAliPpFTrmquGhAO/4pMGPsEWVa35/jPIZEpl +oi3p9ouoDi3LIqgNFE3BlnW0Xf3ThnMw3P9pndA5+2NnJDKYKHp0tPZwGph6qgOf +2nqp0UXZ9U5AqUdjxh7/rAec8NDmAz6TXPSDT2Tc7Xb5bUfWPf2IjxZvcdttjhlf +8CyYVZPGWSWClc7R0yaydubnOH0uOYdQhwwQdGnYA+hca+esWoT8IejmD3oup0KE +tRFFg7oCmD7H4v/r3jZb5XAvyqXTzzxwdwIDAQABo3AwbjAfBgNVHSMEGDAWgBS1 +MicRfZi+HsV8udux5h1T1EWDZTAJBgNVHRMEAjAAMAsGA1UdDwQEAwIE8DAUBgNV +HREEDTALgglsb2NhbGhvc3QwHQYDVR0OBBYEFJFXRB1VgjrDhxU287mxGisg+MzV +MA0GCSqGSIb3DQEBCwUAA4IBAQCXBIKF8ylDhq80OxAe1EaRZKEwVKoVtoXEQLQy +0voiIx00AjfMjUC16+8rAhDDLL5PWi56zonH/2hCfJyVec/oF059MghzCPAALv1G +3koaU+4Tp9S17zLbmlExQN72LOGYs9hswWdrtqpWPeeLxzoYn+LMxANVzhBMz66y +KwnwP/BFyeVKYIVTOZfH67j3PYCLizHCOoPOh0m9/ub8QIgFzKhYl4tWKRzJBug1 +wGYoMIbBasU9N4UoeeWC4OP6YA9MDmpy5D2xFDM0DKW7qYP/KsQSAgPbKKd0a//S +xKv6DFpBeEhh6+d+MAHBYu+m7nTHeO90PCtVSohtTH7fSk0r +-----END CERTIFICATE----- diff --git a/examples/use_webtransport_with_server/server/cert.key b/examples/use_webtransport_with_server/server/cert.key new file mode 100644 index 0000000..84069cb --- /dev/null +++ b/examples/use_webtransport_with_server/server/cert.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC8OPfQam1y5lxO +dhnFJQsxCpTVyyn/niREzsSZ2mx/bV6zXqtGhQ7Fd+/EadsaBZvoXaUAbwaa/tii +WI4CWI+kVOuaq4aEA7/ikwY+wRZVrfn+M8hkSmWiLen2i6gOLcsiqA0UTcGWdbRd +/dOGczDc/2md0Dn7Y2ckMpgoenS09nAamHqqA5/aeqnRRdn1TkCpR2PGHv+sB5zw +0OYDPpNc9INPZNztdvltR9Y9/YiPFm9x222OGV/wLJhVk8ZZJYKVztHTJrJ25uc4 +fS45h1CHDBB0adgD6Fxr56xahPwh6OYPei6nQoS1EUWDugKYPsfi/+veNlvlcC/K +pdPPPHB3AgMBAAECggEADj4FSmjzNTGHJIy9MHS4HxLc5jyERgpSVj6LE9U6Rn4h +H1N3hFOHJZwIsYUNBjAMdw228Yx1JH9KJyaqQDUxUU73sPFvsUeTWnKjk1YK+Zq7 +gueqLySOAjKVNImmwsPmTg4HR1UG4/quFjqhqdfHh8Fv3XgnGwWPhWaqqs1xTUwD +DgatljfrjTt8Nx2RHtwgUQ8ZrAgrb4arQk6WjGqN4smg7n0oszmwmpRtB+9m6aNw +CXULF+qxFSWVb/5fiV4B0D2US7aNHNcrSG4yg3FYgW1HsGdFhGhnUCAGpvrzenmm +7bkijuQgkmcpd0+KMXM7rcayUCcW6wt2/ZTVL0BJ+QKBgQD09Vuymz5SBAkwbjPB +YD4JJFl8ffhW4JiXRa2q5+BwKjesk2nBLUncC8Y+lL9T8tp4+NGGujKjd/EcM03r +viq+Bh3ZZqo0WwFAQscQ9iHncf783JxSfkx60LrKpaSd7GQkvuifRizkZR0z3bde +pMcueNtGTE3hQGxSvZYWHPCqWwKBgQDEtOs8k6yxDL/DfK/ldAL+AFbQ3tj+Zt22 +r6MBi/PuyGsvasXSyX/6n92a8kSC67dOwQyOwquTb/1nTMTErctD2gIyo8hjS9cw ++DdUkgaUxG0xcpWeHHTRHGwu70hqu0SRBw3AURj1NI4eohYt/gcoNEfZaFSgUprB +sDNFGZ8VFQKBgDcpQVr5BpGlgwQ67MCxEYcxfk1AeLnnnbUC5dbEnI/lkd/02i28 +KxO4Ow5ApM0ctQHk1hoGt/yDt/Hnw7ZAfpOIARTBv7ZGgAOehgFVy9C4pPkAHNue +wU4uzsFvh6BgaTS1IOEtBlLwSiEx3mcbqBbY9FfiOu9seHgxZSjZn4BdAoGALNLl +P9qO4ZF8KTnCg1DaVbMSFWqSm/Yo07ZWOMYBggodkqKMDappBV1kjChkwEiibsnC +6M0nd+NvJRjzRbYsuXt2QL/dq/LeSIRnZ1gXM9NG5puryGnHnNcTN+bC479ksn+e +/JH+U/Hz6LsavsRCMUEoljwV/KqWJUjXhgl+nLkCgYA4D8lWtsMkKMh4d6iA4tR3 +93VdUYZG+nwJ0Cj6fgXCapWZ/ncRXlrUzD616W4poT+R9qG1dhNnUiaw81NsbjFj +YK/sBHRTPnm8LbTGZ9WUOPcdQ+T2VNj0DLekiZ6RICazGZLTnj/RaJmJRb1w+ej1 +yNaquFcA6XZ3WLDYrrcvRA== +-----END PRIVATE KEY----- diff --git a/examples/use_window_scroll/src/main.rs b/examples/use_window_scroll/src/main.rs index 627db6d..f659c54 100644 --- a/examples/use_window_scroll/src/main.rs +++ b/examples/use_window_scroll/src/main.rs @@ -16,20 +16,10 @@ fn Demo() -> impl IntoView { document().body().unwrap().append_child(&div).unwrap(); view! { -
- See scroll values in the lower right corner of the screen. -
+
See scroll values in the lower right corner of the screen.
- - Scroll value - -
- x: - {move || format!("{:.1}", x())} -
- y: - {move || format!("{:.1}", y())} -
+ Scroll value +
x: {move || format!("{:.1}", x())}
y: {move || format!("{:.1}", y())}
} } diff --git a/src/core/datetime.rs b/src/core/datetime.rs new file mode 100644 index 0000000..66e72fb --- /dev/null +++ b/src/core/datetime.rs @@ -0,0 +1,16 @@ +use cfg_if::cfg_if; + +/// SSR safe `Date.now()`. +#[inline(always)] +pub(crate) fn now() -> f64 { + cfg_if! { if #[cfg(feature = "ssr")] { + use std::time::{SystemTime, UNIX_EPOCH}; + + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_millis() as f64 + } else { + js_sys::Date::now() + }} +} diff --git a/src/core/direction.rs b/src/core/direction.rs new file mode 100644 index 0000000..a6d1b23 --- /dev/null +++ b/src/core/direction.rs @@ -0,0 +1,41 @@ +/// Direction enum +#[derive(Copy, Clone, Eq, PartialEq, Debug)] +pub enum Direction { + Top, + Bottom, + Left, + Right, +} + +#[derive(Copy, Clone, Default, Debug)] +/// Directions flags +pub struct Directions { + pub left: bool, + pub right: bool, + pub top: bool, + pub bottom: bool, +} + +impl Directions { + /// Returns the value of the provided direction + pub fn get_direction(&self, direction: Direction) -> bool { + match direction { + Direction::Top => self.top, + Direction::Bottom => self.bottom, + Direction::Left => self.left, + Direction::Right => self.right, + } + } + + /// Sets the value of the provided direction + pub fn set_direction(mut self, direction: Direction, value: bool) -> Self { + match direction { + Direction::Top => self.top = value, + Direction::Bottom => self.bottom = value, + Direction::Left => self.left = value, + Direction::Right => self.right = value, + } + + self + } +} diff --git a/src/core/element_maybe_signal.rs b/src/core/element_maybe_signal.rs index 9d972a7..a100b7e 100644 --- a/src/core/element_maybe_signal.rs +++ b/src/core/element_maybe_signal.rs @@ -1,4 +1,5 @@ use crate::{UseDocument, UseWindow}; +use cfg_if::cfg_if; use leptos::html::ElementDescriptor; use leptos::*; use std::marker::PhantomData; @@ -177,7 +178,12 @@ where E: From + 'static, { fn from(target: &'a str) -> Self { - Self::Static(document().query_selector(target).unwrap_or_default()) + cfg_if! { if #[cfg(feature = "ssr")] { + let _ = target; + Self::Static(None) + } else { + Self::Static(document().query_selector(target).unwrap_or_default()) + }} } } @@ -186,7 +192,7 @@ where E: From + 'static, { fn from(target: String) -> Self { - Self::Static(document().query_selector(&target).unwrap_or_default()) + Self::from(target.as_str()) } } @@ -195,10 +201,15 @@ where E: From + 'static, { fn from(signal: Signal) -> Self { - Self::Dynamic( - create_memo(move |_| document().query_selector(&signal.get()).unwrap_or_default()) - .into(), - ) + cfg_if! { if #[cfg(feature = "ssr")] { + let _ = signal; + Self::Dynamic(Signal::derive(|| None)) + } else { + Self::Dynamic( + create_memo(move |_| document().query_selector(&signal.get()).unwrap_or_default()) + .into(), + ) + }} } } diff --git a/src/core/elements_maybe_signal.rs b/src/core/elements_maybe_signal.rs index 8f5eff3..2770d48 100644 --- a/src/core/elements_maybe_signal.rs +++ b/src/core/elements_maybe_signal.rs @@ -1,5 +1,6 @@ use crate::core::ElementMaybeSignal; use crate::{UseDocument, UseWindow}; +use cfg_if::cfg_if; use leptos::html::ElementDescriptor; use leptos::*; use std::marker::PhantomData; @@ -178,17 +179,31 @@ where E: From + 'static, { fn from(target: &'a str) -> Self { - if let Ok(node_list) = document().query_selector_all(target) { - let mut list = Vec::with_capacity(node_list.length() as usize); - for i in 0..node_list.length() { - let node = node_list.get(i).expect("checked the range"); - list.push(Some(node)); - } + cfg_if! { if #[cfg(feature = "ssr")] { + if let Ok(node_list) = document().query_selector_all(target) { + let mut list = Vec::with_capacity(node_list.length() as usize); + for i in 0..node_list.length() { + let node = node_list.get(i).expect("checked the range"); + list.push(Some(node)); + } - Self::Static(list) + Self::Static(list) + } else { + Self::Static(vec![]) + } } else { + let _ = target; Self::Static(vec![]) - } + }} + } +} + +impl From for ElementsMaybeSignal +where + E: From + 'static, +{ + fn from(target: String) -> Self { + Self::from(target.as_str()) } } @@ -197,21 +212,26 @@ where E: From + 'static, { fn from(signal: Signal) -> Self { - Self::Dynamic( - create_memo(move |_| { - if let Ok(node_list) = document().query_selector_all(&signal.get()) { - let mut list = Vec::with_capacity(node_list.length() as usize); - for i in 0..node_list.length() { - let node = node_list.get(i).expect("checked the range"); - list.push(Some(node)); + cfg_if! { if #[cfg(feature = "ssr")] { + Self::Dynamic( + create_memo(move |_| { + if let Ok(node_list) = document().query_selector_all(&signal.get()) { + let mut list = Vec::with_capacity(node_list.length() as usize); + for i in 0..node_list.length() { + let node = node_list.get(i).expect("checked the range"); + list.push(Some(node)); + } + list + } else { + vec![] } - list - } else { - vec![] - } - }) - .into(), - ) + }) + .into(), + ) + } else { + let _ = signal; + Self::Dynamic(Signal::derive(Vec::new)) + }} } } diff --git a/src/core/maybe_rw_signal.rs b/src/core/maybe_rw_signal.rs index 6aad544..f1e8bac 100644 --- a/src/core/maybe_rw_signal.rs +++ b/src/core/maybe_rw_signal.rs @@ -1,4 +1,5 @@ use leptos::*; +use std::fmt::Debug; pub enum MaybeRwSignal where @@ -33,6 +34,16 @@ impl Default for MaybeRwSignal { } } +impl Debug for MaybeRwSignal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Static(t) => f.debug_tuple("Static").field(t).finish(), + Self::DynamicRw(r, w) => f.debug_tuple("DynamicRw").field(r).field(w).finish(), + Self::DynamicRead(s) => f.debug_tuple("DynamicRead").field(s).finish(), + } + } +} + impl From> for MaybeRwSignal { fn from(s: Signal) -> Self { Self::DynamicRead(s) diff --git a/src/core/mod.rs b/src/core/mod.rs index 2a8c2e0..9abe354 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,17 +1,23 @@ mod connection_ready_state; +mod datetime; +mod direction; mod element_maybe_signal; mod elements_maybe_signal; mod maybe_rw_signal; mod pointer_type; mod position; mod size; +mod ssr_safe_method; mod storage; pub use connection_ready_state::*; +pub(crate) use datetime::*; +pub use direction::*; pub use element_maybe_signal::*; pub use elements_maybe_signal::*; pub use maybe_rw_signal::*; pub use pointer_type::*; pub use position::*; pub use size::*; +pub(crate) use ssr_safe_method::*; pub use storage::*; diff --git a/src/core/ssr_safe_method.rs b/src/core/ssr_safe_method.rs new file mode 100644 index 0000000..9ffa513 --- /dev/null +++ b/src/core/ssr_safe_method.rs @@ -0,0 +1,19 @@ +macro_rules! impl_ssr_safe_method { + ( + $(#[$attr:meta])* + $method:ident(&self$(, $p_name:ident: $p_ty:ty)*) -> $return_ty:ty + $(; $($post_fix:tt)+)? + ) => { + $(#[$attr])* + #[inline(always)] + pub fn $method(&self, $($p_name: $p_ty),*) -> $return_ty { + self.0.as_ref() + .map( + |w| w.$method($($p_name),*) + ) + $($($post_fix)+)? + } + }; +} + +pub(crate) use impl_ssr_safe_method; diff --git a/src/core/storage.rs b/src/core/storage.rs index 72da8fa..8390217 100644 --- a/src/core/storage.rs +++ b/src/core/storage.rs @@ -2,7 +2,6 @@ use leptos::window; use wasm_bindgen::JsValue; /// Local or session storage or a custom store that is a `web_sys::Storage`. -// #[doc(cfg(feature = "storage"))] #[derive(Default)] pub enum StorageType { #[default] diff --git a/src/lib.rs b/src/lib.rs index 13ff243..1dd36ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,24 +1,16 @@ // #![feature(doc_cfg)] //! Collection of essential Leptos utilities inspired by SolidJS USE / VueUse -use cfg_if::cfg_if; - pub mod core; #[cfg(feature = "docs")] pub mod docs; #[cfg(feature = "math")] pub mod math; -#[cfg(feature = "storage")] pub mod storage; pub mod utils; cfg_if! { if #[cfg(web_sys_unstable_apis)] { - mod use_element_size; - mod use_resize_observer; mod use_webtransport; - - pub use use_element_size::*; - pub use use_resize_observer::*; pub use use_webtransport::*; }} @@ -35,16 +27,19 @@ mod use_color_mode; mod use_css_var; mod use_cycle_list; mod use_debounce_fn; +mod use_display_media; mod use_document; mod use_document_visibility; mod use_draggable; mod use_drop_zone; mod use_element_hover; +mod use_element_size; mod use_element_visibility; mod use_event_listener; mod use_favicon; mod use_geolocation; mod use_idle; +mod use_infinite_scroll; mod use_intersection_observer; mod use_interval; mod use_interval_fn; @@ -55,11 +50,15 @@ mod use_mutation_observer; mod use_preferred_contrast; mod use_preferred_dark; mod use_raf_fn; +mod use_resize_observer; mod use_scroll; +mod use_service_worker; +mod use_sorted; mod use_supported; mod use_throttle_fn; mod use_timestamp; mod use_to_string; +mod use_web_notification; mod use_websocket; mod use_window; mod use_window_focus; @@ -83,16 +82,19 @@ pub use use_color_mode::*; pub use use_css_var::*; pub use use_cycle_list::*; pub use use_debounce_fn::*; +pub use use_display_media::*; pub use use_document::*; pub use use_document_visibility::*; pub use use_draggable::*; pub use use_drop_zone::*; pub use use_element_hover::*; +pub use use_element_size::*; pub use use_element_visibility::*; pub use use_event_listener::*; pub use use_favicon::*; pub use use_geolocation::*; pub use use_idle::*; +pub use use_infinite_scroll::*; pub use use_intersection_observer::*; pub use use_interval::*; pub use use_interval_fn::*; @@ -103,11 +105,15 @@ pub use use_mutation_observer::*; pub use use_preferred_contrast::*; pub use use_preferred_dark::*; pub use use_raf_fn::*; +pub use use_resize_observer::*; pub use use_scroll::*; +pub use use_service_worker::*; +pub use use_sorted::*; pub use use_supported::*; pub use use_throttle_fn::*; pub use use_timestamp::*; pub use use_to_string::*; +pub use use_web_notification::*; pub use use_websocket::*; pub use use_webtransport::*; pub use use_window::*; diff --git a/src/on_click_outside.rs b/src/on_click_outside.rs index 1ac1cca..3d1a542 100644 --- a/src/on_click_outside.rs +++ b/src/on_click_outside.rs @@ -125,7 +125,7 @@ where }) }; - let target = (target).into(); + let target = target.into(); let listener = { let should_listen = Rc::clone(&should_listen); diff --git a/src/storage/codec_json.rs b/src/storage/codec_json.rs new file mode 100644 index 0000000..1a6a3a9 --- /dev/null +++ b/src/storage/codec_json.rs @@ -0,0 +1,151 @@ +use super::Codec; + +/// A codec for storing JSON messages that relies on [`serde_json`] to parse. +/// +/// ## Example +/// ``` +/// # use leptos::*; +/// # use leptos_use::storage::{StorageType, use_local_storage, use_session_storage, use_storage, UseStorageOptions, JsonCodec}; +/// # use serde::{Deserialize, Serialize}; +/// # +/// # pub fn Demo() -> impl IntoView { +/// // Primitive types: +/// let (get, set, remove) = use_local_storage::("my-key"); +/// +/// // Structs: +/// #[derive(Serialize, Deserialize, Clone, Default, PartialEq)] +/// pub struct MyState { +/// pub hello: String, +/// } +/// let (get, set, remove) = use_local_storage::("my-struct-key"); +/// # view! { } +/// # } +/// ``` +/// +/// ## Versioning +/// +/// If the JSON decoder fails, the storage hook will return `T::Default` dropping the stored JSON value. See [`Codec`](super::Codec) for general information on codec versioning. +/// +/// ### Rely on serde +/// This codec uses [`serde_json`] under the hood. A simple way to avoid complex versioning is to rely on serde's [field attributes](https://serde.rs/field-attrs.html) such as [`serde(default)`](https://serde.rs/field-attrs.html#default) and [`serde(rename = "...")`](https://serde.rs/field-attrs.html#rename). +/// +/// ### String replacement +/// Previous versions of leptos-use offered a `merge_defaults` fn to rewrite the encoded value. This is possible by wrapping the codec but should be avoided. +/// +/// ``` +/// # use leptos::*; +/// # use leptos_use::storage::{StorageType, use_local_storage, use_session_storage, use_storage, UseStorageOptions, Codec, JsonCodec}; +/// # use serde::{Deserialize, Serialize}; +/// # +/// # pub fn Demo() -> impl IntoView { +/// #[derive(Serialize, Deserialize, Clone, Default, PartialEq)] +/// pub struct MyState { +/// pub hello: String, +/// pub greeting: String, +/// } +/// +/// #[derive(Clone, Default)] +/// pub struct MyStateCodec(); +/// impl Codec for MyStateCodec { +/// type Error = serde_json::Error; +/// +/// fn encode(&self, val: &MyState) -> Result { +/// serde_json::to_string(val) +/// } +/// +/// fn decode(&self, stored_value: String) -> Result { +/// let default_value = MyState::default(); +/// let rewritten = if stored_value.contains(r#""greeting":"#) { +/// stored_value +/// } else { +/// // add "greeting": "Hello" to the string +/// stored_value.replace("}", &format!(r#""greeting": "{}"}}"#, default_value.greeting)) +/// }; +/// serde_json::from_str(&rewritten) +/// } +/// } +/// +/// let (get, set, remove) = use_local_storage::("my-struct-key"); +/// # view! { } +/// # } +/// ``` +/// +/// ### Transform a `JsValue` +/// A better alternative to string replacement might be to parse the JSON then transform the resulting `JsValue` before decoding it to to your struct again. +/// +/// ``` +/// # use leptos::*; +/// # use leptos_use::storage::{StorageType, use_local_storage, use_session_storage, use_storage, UseStorageOptions, Codec, JsonCodec}; +/// # use serde::{Deserialize, Serialize}; +/// # use serde_json::json; +/// # +/// # pub fn Demo() -> impl IntoView { +/// #[derive(Serialize, Deserialize, Clone, Default, PartialEq)] +/// pub struct MyState { +/// pub hello: String, +/// pub greeting: String, +/// } +/// +/// #[derive(Clone, Default)] +/// pub struct MyStateCodec(); +/// impl Codec for MyStateCodec { +/// type Error = serde_json::Error; +/// +/// fn encode(&self, val: &MyState) -> Result { +/// serde_json::to_string(val) +/// } +/// +/// fn decode(&self, stored_value: String) -> Result { +/// let mut val: serde_json::Value = serde_json::from_str(&stored_value)?; +/// // add "greeting": "Hello" to the object if it's missing +/// if let Some(obj) = val.as_object_mut() { +/// if !obj.contains_key("greeting") { +/// obj.insert("greeting".to_string(), json!("Hello")); +/// } +/// serde_json::from_value(val) +/// } else { +/// Ok(MyState::default()) +/// } +/// } +/// } +/// +/// let (get, set, remove) = use_local_storage::("my-struct-key"); +/// # view! { } +/// # } +/// ``` +#[derive(Clone, Default, PartialEq)] +pub struct JsonCodec; + +impl Codec for JsonCodec { + type Error = serde_json::Error; + + fn encode(&self, val: &T) -> Result { + serde_json::to_string(val) + } + + fn decode(&self, str: String) -> Result { + serde_json::from_str(&str) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_json_codec() { + #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] + struct Test { + s: String, + i: i32, + } + let t = Test { + s: String::from("party time 🎉"), + i: 42, + }; + let codec = JsonCodec; + let enc = codec.encode(&t).unwrap(); + let dec: Test = codec.decode(enc).unwrap(); + assert_eq!(dec, t); + } +} diff --git a/src/storage/codec_prost.rs b/src/storage/codec_prost.rs new file mode 100644 index 0000000..2311e28 --- /dev/null +++ b/src/storage/codec_prost.rs @@ -0,0 +1,79 @@ +use super::Codec; +use base64::Engine; +use thiserror::Error; + +/// A codec for storing ProtoBuf messages that relies on [`prost`] to parse. +/// +/// [Protocol buffers](https://protobuf.dev/overview/) is a serialisation format useful for long-term storage. It provides semantics for versioning that are not present in JSON or other formats. [`prost`] is a Rust implementation of Protocol Buffers. +/// +/// This codec uses [`prost`] to encode the message and then [`base64`](https://docs.rs/base64) to represent the bytes as a string. +/// +/// ## Example +/// ``` +/// # use leptos::*; +/// # use leptos_use::storage::{StorageType, use_local_storage, use_session_storage, use_storage, UseStorageOptions, ProstCodec}; +/// # +/// # pub fn Demo() -> impl IntoView { +/// // Primitive types: +/// let (get, set, remove) = use_local_storage::("my-key"); +/// +/// // Structs: +/// #[derive(Clone, PartialEq, prost::Message)] +/// pub struct MyState { +/// #[prost(string, tag = "1")] +/// pub hello: String, +/// } +/// let (get, set, remove) = use_local_storage::("my-struct-key"); +/// # view! { } +/// # } +/// ``` +/// +/// Note: we've defined and used the `prost` attribute here for brevity. Alternate usage would be to describe the message in a .proto file and use [`prost_build`](https://docs.rs/prost-build) to auto-generate the Rust code. +#[derive(Clone, Default, PartialEq)] +pub struct ProstCodec; + +#[derive(Error, Debug, PartialEq)] +pub enum ProstCodecError { + #[error("failed to decode base64")] + DecodeBase64(base64::DecodeError), + #[error("failed to decode protobuf")] + DecodeProst(#[from] prost::DecodeError), +} + +impl Codec for ProstCodec { + type Error = ProstCodecError; + + fn encode(&self, val: &T) -> Result { + let buf = val.encode_to_vec(); + Ok(base64::engine::general_purpose::STANDARD.encode(buf)) + } + + fn decode(&self, str: String) -> Result { + let buf = base64::engine::general_purpose::STANDARD + .decode(str) + .map_err(ProstCodecError::DecodeBase64)?; + T::decode(buf.as_slice()).map_err(ProstCodecError::DecodeProst) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_prost_codec() { + #[derive(Clone, PartialEq, prost::Message)] + struct Test { + #[prost(string, tag = "1")] + s: String, + #[prost(int32, tag = "2")] + i: i32, + } + let t = Test { + s: String::from("party time 🎉"), + i: 42, + }; + let codec = ProstCodec; + assert_eq!(codec.decode(codec.encode(&t).unwrap()), Ok(t)); + } +} diff --git a/src/storage/codec_string.rs b/src/storage/codec_string.rs new file mode 100644 index 0000000..c7a8049 --- /dev/null +++ b/src/storage/codec_string.rs @@ -0,0 +1,44 @@ +use super::Codec; +use std::str::FromStr; + +/// A codec for strings that relies on [`FromStr`] and [`ToString`] to parse. +/// +/// This makes simple key / value easy to use for primitive types. It is also useful for encoding simple data structures without depending on serde. +/// +/// ## Example +/// ``` +/// # use leptos::*; +/// # use leptos_use::storage::{StorageType, use_local_storage, use_session_storage, use_storage, UseStorageOptions, StringCodec}; +/// # +/// # pub fn Demo() -> impl IntoView { +/// let (get, set, remove) = use_local_storage::("my-key"); +/// # view! { } +/// # } +/// ``` +#[derive(Clone, Default, PartialEq)] +pub struct StringCodec; + +impl Codec for StringCodec { + type Error = T::Err; + + fn encode(&self, val: &T) -> Result { + Ok(val.to_string()) + } + + fn decode(&self, str: String) -> Result { + T::from_str(&str) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_string_codec() { + let s = String::from("party time 🎉"); + let codec = StringCodec; + assert_eq!(codec.encode(&s), Ok(s.clone())); + assert_eq!(codec.decode(s.clone()), Ok(s)); + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs index bc0844b..c5cd494 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -1,8 +1,18 @@ -mod shared; +#[cfg(feature = "serde")] +mod codec_json; +#[cfg(feature = "prost")] +mod codec_prost; +mod codec_string; mod use_local_storage; mod use_session_storage; mod use_storage; +pub use crate::core::StorageType; +#[cfg(feature = "serde")] +pub use codec_json::*; +#[cfg(feature = "prost")] +pub use codec_prost::*; +pub use codec_string::*; pub use use_local_storage::*; pub use use_session_storage::*; pub use use_storage::*; diff --git a/src/storage/shared.rs b/src/storage/shared.rs deleted file mode 100644 index bd9ef93..0000000 --- a/src/storage/shared.rs +++ /dev/null @@ -1,96 +0,0 @@ -use crate::filter_builder_methods; -use crate::storage::{StorageType, UseStorageError, UseStorageOptions}; -use crate::utils::{DebounceOptions, FilterOptions, ThrottleOptions}; -use default_struct_builder::DefaultBuilder; -use leptos::*; -use std::rc::Rc; - -macro_rules! use_specific_storage { - ($(#[$outer:meta])* - $storage_name:ident - #[$simple_func:meta] - ) => { - paste! { - $(#[$outer])* - pub fn []( - key: &str, - defaults: D, - ) -> (Signal, WriteSignal, impl Fn() + Clone) - where - for<'de> T: Serialize + Deserialize<'de> + Clone + 'static, - D: Into>, - T: Clone, - { - [](key, defaults, UseSpecificStorageOptions::default()) - } - - /// Version of - #[$simple_func] - /// that accepts [`UseSpecificStorageOptions`]. See - #[$simple_func] - /// for how to use. - pub fn []( - key: &str, - defaults: D, - options: UseSpecificStorageOptions, - ) -> (Signal, WriteSignal, impl Fn() + Clone) - where - for<'de> T: Serialize + Deserialize<'de> + Clone + 'static, - D: Into>, - T: Clone, - { - use_storage_with_options(key, defaults, options.into_storage_options(StorageType::[<$storage_name:camel>])) - } - } - }; -} - -pub(crate) use use_specific_storage; - -/// Options for [`use_local_storage_with_options`]. -// #[doc(cfg(feature = "storage"))] -#[derive(DefaultBuilder)] -pub struct UseSpecificStorageOptions { - /// Listen to changes to this storage key from somewhere else. Defaults to true. - listen_to_storage_changes: bool, - /// If no value for the give key is found in the storage, write it. Defaults to true. - write_defaults: bool, - /// Takes the serialized (json) stored value and the default value and returns a merged version. - /// Defaults to simply returning the stored value. - merge_defaults: fn(&str, &T) -> String, - /// Optional callback whenever an error occurs. The callback takes an argument of type [`UseStorageError`]. - on_error: Rc, - - /// Debounce or throttle the writing to storage whenever the value changes. - filter: FilterOptions, -} - -impl Default for UseSpecificStorageOptions { - fn default() -> Self { - Self { - listen_to_storage_changes: true, - write_defaults: true, - merge_defaults: |stored_value, _default_value| stored_value.to_string(), - on_error: Rc::new(|_| ()), - filter: Default::default(), - } - } -} - -impl UseSpecificStorageOptions { - pub fn into_storage_options(self, storage_type: StorageType) -> UseStorageOptions { - UseStorageOptions { - storage_type, - listen_to_storage_changes: self.listen_to_storage_changes, - write_defaults: self.write_defaults, - merge_defaults: self.merge_defaults, - on_error: self.on_error, - filter: self.filter, - } - } - - filter_builder_methods!( - /// the serializing and storing into storage - filter - ); -} diff --git a/src/storage/use_local_storage.rs b/src/storage/use_local_storage.rs index 3ed7716..4479a4a 100644 --- a/src/storage/use_local_storage.rs +++ b/src/storage/use_local_storage.rs @@ -1,22 +1,36 @@ -use crate::core::MaybeRwSignal; -use crate::storage::shared::{use_specific_storage, UseSpecificStorageOptions}; -use crate::storage::{use_storage_with_options, StorageType}; -use leptos::*; -use paste::paste; -use serde::{Deserialize, Serialize}; +use super::{use_storage_with_options, Codec, StorageType, UseStorageOptions}; +use leptos::signal_prelude::*; -use_specific_storage!( - /// Reactive [LocalStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) - /// - /// ## Usage - /// - /// Please refer to [`use_storage`] - /// - /// ## See also - /// - /// * [`use_storage`] - /// * [`use_session_storage`] - // #[doc(cfg(feature = "storage"))] - local - /// [`use_local_storage`] -); +/// Reactive [LocalStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). +/// +/// LocalStorage stores data in the browser with no expiration time. Access is given to all pages from the same origin (e.g., all pages from "https://example.com" share the same origin). While data doesn't expire the user can view, modify and delete all data stored. Browsers allow 5MB of data to be stored. +/// +/// This is contrast to [`use_session_storage`] which clears data when the page session ends and is not shared. +/// +/// ## Usage +/// See [`use_storage`] for more details on how to use. +pub fn use_local_storage( + key: impl AsRef, +) -> (Signal, WriteSignal, impl Fn() + Clone) +where + T: Clone + Default + PartialEq, + C: Codec + Default, +{ + use_storage_with_options( + StorageType::Local, + key, + UseStorageOptions::::default(), + ) +} + +/// Accepts [`UseStorageOptions`]. See [`use_local_storage`] for details. +pub fn use_local_storage_with_options( + key: impl AsRef, + options: UseStorageOptions, +) -> (Signal, WriteSignal, impl Fn() + Clone) +where + T: Clone + PartialEq, + C: Codec, +{ + use_storage_with_options(StorageType::Local, key, options) +} diff --git a/src/storage/use_session_storage.rs b/src/storage/use_session_storage.rs index dca9b95..127e457 100644 --- a/src/storage/use_session_storage.rs +++ b/src/storage/use_session_storage.rs @@ -1,22 +1,36 @@ -use crate::core::MaybeRwSignal; -use crate::storage::shared::{use_specific_storage, UseSpecificStorageOptions}; -use crate::storage::{use_storage_with_options, StorageType}; -use leptos::*; -use paste::paste; -use serde::{Deserialize, Serialize}; +use super::{use_storage_with_options, Codec, StorageType, UseStorageOptions}; +use leptos::signal_prelude::*; -use_specific_storage!( - /// Reactive [SessionStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage) - /// - /// ## Usage - /// - /// Please refer to [`use_storage`] - /// - /// ## See also - /// - /// * [`use_storage`] - /// * [`use_local_storage`] - // #[doc(cfg(feature = "storage"))] - session - /// [`use_session_storage`] -); +/// Reactive [SessionStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage). +/// +/// SessionStorages stores data in the browser that is deleted when the page session ends. A page session ends when the browser closes the tab. Data is not shared between pages. While data doesn't expire the user can view, modify and delete all data stored. Browsers allow 5MB of data to be stored. +/// +/// Use [`use_local_storage`] to store data that is shared amongst all pages with the same origin and persists between page sessions. +/// +/// ## Usage +/// See [`use_storage`] for more details on how to use. +pub fn use_session_storage( + key: impl AsRef, +) -> (Signal, WriteSignal, impl Fn() + Clone) +where + T: Clone + Default + PartialEq, + C: Codec + Default, +{ + use_storage_with_options( + StorageType::Session, + key, + UseStorageOptions::::default(), + ) +} + +/// Accepts [`UseStorageOptions`]. See [`use_session_storage`] for details. +pub fn use_session_storage_with_options( + key: impl AsRef, + options: UseStorageOptions, +) -> (Signal, WriteSignal, impl Fn() + Clone) +where + T: Clone + PartialEq, + C: Codec, +{ + use_storage_with_options(StorageType::Session, key, options) +} diff --git a/src/storage/use_storage.rs b/src/storage/use_storage.rs index 7122882..3c66fce 100644 --- a/src/storage/use_storage.rs +++ b/src/storage/use_storage.rs @@ -1,26 +1,17 @@ -#![cfg_attr(feature = "ssr", allow(unused_variables, unused_imports, dead_code))] - -use crate::core::MaybeRwSignal; -use crate::utils::FilterOptions; +use crate::storage::StringCodec; use crate::{ - filter_builder_methods, use_event_listener, watch_pausable_with_options, DebounceOptions, - ThrottleOptions, WatchOptions, WatchPausableReturn, + core::{MaybeRwSignal, StorageType}, + utils::FilterOptions, }; use cfg_if::cfg_if; -use default_struct_builder::DefaultBuilder; -use js_sys::Reflect; use leptos::*; -use serde::{Deserialize, Serialize}; -use serde_json::Error; use std::rc::Rc; -use std::time::Duration; -use wasm_bindgen::{JsCast, JsValue}; +use thiserror::Error; +use wasm_bindgen::JsValue; -pub use crate::core::StorageType; +const INTERNAL_STORAGE_EVENT: &str = "leptos-use-storage"; -const CUSTOM_STORAGE_EVENT_NAME: &str = "leptos-use-storage"; - -/// Reactive [LocalStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) / [SessionStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage). +/// Reactive [Storage](https://developer.mozilla.org/en-US/docs/Web/API/Storage). /// /// ## Demo /// @@ -28,471 +19,392 @@ const CUSTOM_STORAGE_EVENT_NAME: &str = "leptos-use-storage"; /// /// ## Usage /// -/// It returns a triplet `(read_signal, write_signal, delete_from_storage_func)` of type `(ReadSignal, WriteSignal, Fn())`. +/// Pass a [`StorageType`] to determine the kind of key-value browser storage to use. The specified key is where data is stored. All values are stored as UTF-16 strings which is then encoded and decoded via the given [`Codec`]. This value is synced with other calls using the same key on the smae page and across tabs for local storage. See [`UseStorageOptions`] to see how behaviour can be further customised. /// -/// Values are (de-)serialized to/from JSON using [`serde`](https://serde.rs/). +/// See [`Codec`] for more details on how to handle versioning--dealing with data that can outlast your code. +/// +/// Returns a triplet `(read_signal, write_signal, delete_from_storage_fn)`. +/// +/// ## Example /// /// ``` /// # use leptos::*; -/// # use leptos_use::storage::{StorageType, use_storage, use_storage_with_options, UseStorageOptions}; +/// # use leptos_use::storage::{StorageType, use_local_storage, use_session_storage, use_storage_with_options, UseStorageOptions, StringCodec, JsonCodec, ProstCodec}; /// # use serde::{Deserialize, Serialize}; /// # -/// #[derive(Serialize, Deserialize, Clone)] -/// pub struct MyState { -/// pub hello: String, -/// pub greeting: String, -/// } -/// /// # pub fn Demo() -> impl IntoView { -/// // bind struct. Must be serializable. -/// let (state, set_state, _) = use_storage( -/// "my-state", -/// MyState { -/// hello: "hi".to_string(), -/// greeting: "Hello".to_string() -/// }, -/// ); // returns Signal +/// // Binds a struct: +/// let (state, set_state, _) = use_local_storage::("my-state"); /// -/// // bind bool. -/// let (flag, set_flag, remove_flag) = use_storage("my-flag", true); // returns Signal +/// // Binds a bool, stored as a string: +/// let (flag, set_flag, remove_flag) = use_session_storage::("my-flag"); /// -/// // bind number -/// let (count, set_count, _) = use_storage("my-count", 0); // returns Signal +/// // Binds a number, stored as a string: +/// let (count, set_count, _) = use_session_storage::("my-count"); +/// // Binds a number, stored in JSON: +/// let (count, set_count, _) = use_session_storage::("my-count-kept-in-js"); /// -/// // bind string with SessionStorage -/// let (id, set_id, _) = use_storage_with_options( +/// // Bind string with SessionStorage stored in ProtoBuf format: +/// let (id, set_id, _) = use_storage_with_options::( +/// StorageType::Session, /// "my-id", -/// "some_string_id".to_string(), -/// UseStorageOptions::default().storage_type(StorageType::Session), +/// UseStorageOptions::default(), /// ); /// # view! { } /// # } -/// ``` /// -/// ## Merge Defaults -/// -/// By default, [`use_storage`] will use the value from storage if it is present and ignores the default value. -/// Be aware that when you add more properties to the default value, the key might be `None` -/// (in the case of an `Option` field) if client's storage does not have that key -/// or deserialization might fail altogether. -/// -/// Let's say you had a struct `MyState` that has been saved to storage -/// -/// ```ignore -/// #[derive(Serialize, Deserialize, Clone)] -/// struct MyState { -/// hello: String, -/// } -/// -/// let (state, .. ) = use_storage("my-state", MyState { hello: "hello" }); -/// ``` -/// -/// Now, in a newer version you added a field `greeting` to `MyState`. -/// -/// ```ignore -/// #[derive(Serialize, Deserialize, Clone)] -/// struct MyState { -/// hello: String, -/// greeting: String, -/// } -/// -/// let (state, .. ) = use_storage( -/// "my-state", -/// MyState { hello: "hi", greeting: "whatsup" }, -/// ); // fails to deserialize -> default value -/// ``` -/// -/// This will fail to deserialize the stored string `{"hello": "hello"}` because it has no field `greeting`. -/// Hence it just uses the new default value provided and the previously saved value is lost. -/// -/// To mitigate that you can provide a `merge_defaults` option. This is a pure function pointer -/// that takes the serialized (to json) stored value and the default value as arguments -/// and should return the serialized merged value. -/// -/// ``` -/// # use leptos::*; -/// # use leptos_use::storage::{use_storage_with_options, UseStorageOptions}; -/// # use serde::{Deserialize, Serialize}; -/// # -/// #[derive(Serialize, Deserialize, Clone)] +/// // Data stored in JSON must implement Serialize, Deserialize: +/// #[derive(Serialize, Deserialize, Clone, PartialEq)] /// pub struct MyState { /// pub hello: String, /// pub greeting: String, /// } -/// # -/// # pub fn Demo() -> impl IntoView { -/// let (state, set_state, _) = use_storage_with_options( -/// "my-state", -/// MyState { -/// hello: "hi".to_string(), -/// greeting: "Hello".to_string() -/// }, -/// UseStorageOptions::::default().merge_defaults(|stored_value, default_value| { -/// if stored_value.contains(r#""greeting":"#) { -/// stored_value.to_string() -/// } else { -/// // add "greeting": "Hello" to the string -/// stored_value.replace("}", &format!(r#""greeting": "{}"}}"#, default_value.greeting)) +/// +/// // Default can be used to implement intial or deleted values. +/// // You can also use a signal via UseStorageOptions::default_value` +/// impl Default for MyState { +/// fn default() -> Self { +/// Self { +/// hello: "hi".to_string(), +/// greeting: "Hello".to_string() /// } -/// }), -/// ); -/// # -/// # view! { } -/// # } +/// } +/// } /// ``` -/// -/// ## Filter Storage Write -/// -/// You can specify `debounce` or `throttle` options for limiting writes to storage. -/// -/// ## Server-Side Rendering -/// -/// On the server this falls back to a `create_signal(default)` and an empty remove function. -/// -/// ## See also -/// -/// * [`use_local_storage`] -/// * [`use_session_storage`] -// #[doc(cfg(feature = "storage"))] -pub fn use_storage(key: &str, defaults: D) -> (Signal, WriteSignal, impl Fn() + Clone) -where - for<'de> T: Serialize + Deserialize<'de> + Clone + 'static, - D: Into>, - T: Clone, -{ - use_storage_with_options(key, defaults, UseStorageOptions::default()) +#[inline(always)] +pub fn use_storage( + storage_type: StorageType, + key: impl AsRef, +) -> (Signal, WriteSignal, impl Fn() + Clone) { + use_storage_with_options::(storage_type, key, UseStorageOptions::default()) } -/// Version of [`use_storage`] that accepts [`UseStorageOptions`]. See [`use_storage`] for how to use. -// #[doc(cfg(feature = "storage"))] -pub fn use_storage_with_options( - key: &str, - defaults: D, - options: UseStorageOptions, +/// Version of [`use_storage`] that accepts [`UseStorageOptions`]. +pub fn use_storage_with_options( + storage_type: StorageType, + key: impl AsRef, + options: UseStorageOptions, ) -> (Signal, WriteSignal, impl Fn() + Clone) where - for<'de> T: Serialize + Deserialize<'de> + Clone + 'static, - D: Into>, - T: Clone, + T: Clone + PartialEq, + C: Codec, { - let defaults = defaults.into(); - let UseStorageOptions { - storage_type, - listen_to_storage_changes, - write_defaults, - merge_defaults, + codec, on_error, + listen_to_storage_changes, + initial_value, filter, } = options; - let (data, set_data) = defaults.into_signal(); - - let raw_init = data.get_untracked(); + let (data, set_data) = initial_value.into_signal(); + let default = data.get_untracked(); cfg_if! { if #[cfg(feature = "ssr")] { - let remove: Rc = Rc::new(|| {}); + let _ = codec; + let _ = on_error; + let _ = listen_to_storage_changes; + let _ = filter; + let _ = storage_type; + let _ = key; + let _ = INTERNAL_STORAGE_EVENT; + + + let remove = move || { + set_data.set(default.clone()); + }; + + (data.into(), set_data, remove) } else { - let storage = storage_type.into_storage(); + use crate::{use_event_listener, use_window, watch_with_options, WatchOptions}; - let remove: Rc = match storage { - Ok(Some(storage)) => { - let write = { - let on_error = on_error.clone(); - let storage = storage.clone(); - let key = key.to_string(); + // Get storage API + let storage = storage_type + .into_storage() + .map_err(UseStorageError::StorageNotAvailable) + .and_then(|s| s.ok_or(UseStorageError::StorageReturnedNone)); + let storage = handle_error(&on_error, storage); - Rc::new(move |v: &T| { - match serde_json::to_string(&v) { - Ok(ref serialized) => match storage.get_item(&key) { - Ok(old_value) => { - if old_value.as_ref() != Some(serialized) { - if let Err(e) = storage.set_item(&key, serialized) { - on_error(UseStorageError::StorageAccessError(e)); - } else { - let mut event_init = web_sys::CustomEventInit::new(); - event_init.detail( - &StorageEventDetail { - key: Some(key.clone()), - old_value, - new_value: Some(serialized.clone()), - storage_area: Some(storage.clone()), - } - .into(), - ); - - // importantly this should _not_ be a StorageEvent since those cannot - // be constructed with a non-built-in storage area - let _ = window().dispatch_event( - &web_sys::CustomEvent::new_with_event_init_dict( - CUSTOM_STORAGE_EVENT_NAME, - &event_init, - ) - .expect("Failed to create CustomEvent"), - ); - } - } - } - Err(e) => { - on_error.clone()(UseStorageError::StorageAccessError(e)); - } - }, - Err(e) => { - on_error.clone()(UseStorageError::SerializationError(e)); - } - } - }) - }; - - let read = { - let storage = storage.clone(); - let on_error = on_error.clone(); - let key = key.to_string(); - let raw_init = raw_init.clone(); - - Rc::new( - move |event_detail: Option| -> Option { - let serialized_init = match serde_json::to_string(&raw_init) { - Ok(serialized) => Some(serialized), - Err(e) => { - on_error.clone()(UseStorageError::DefaultSerializationError(e)); - None - } - }; - - let raw_value = if let Some(event_detail) = event_detail { - event_detail.new_value - } else { - match storage.get_item(&key) { - Ok(raw_value) => match raw_value { - Some(raw_value) => Some(merge_defaults(&raw_value, &raw_init)), - None => serialized_init.clone(), - }, - Err(e) => { - on_error.clone()(UseStorageError::StorageAccessError(e)); - None - } - } - }; - - match raw_value { - Some(raw_value) => match serde_json::from_str(&raw_value) { - Ok(v) => Some(v), - Err(e) => { - on_error.clone()(UseStorageError::SerializationError(e)); - None - } - }, - None => { - if let Some(serialized_init) = &serialized_init { - if write_defaults { - if let Err(e) = storage.set_item(&key, serialized_init) { - on_error(UseStorageError::StorageAccessError(e)); - } - } - } - - Some(raw_init.clone()) - } - } - }, - ) - }; - - let WatchPausableReturn { - pause: pause_watch, - resume: resume_watch, - .. - } = watch_pausable_with_options( - move || data.get(), - move |data, _, _| Rc::clone(&write)(data), - WatchOptions::default().filter(filter), - ); - - let update = { - let key = key.to_string(); - let storage = storage.clone(); - let raw_init = raw_init.clone(); - - Rc::new(move |event_detail: Option| { - if let Some(event_detail) = &event_detail { - if event_detail.storage_area != Some(storage.clone()) { - return; - } - - match &event_detail.key { - None => { - set_data.set(raw_init.clone()); - return; - } - Some(event_key) => { - if event_key != &key { - return; - } - } - }; - } - - pause_watch(); - - if let Some(value) = read(event_detail.clone()) { - set_data.set(value); - } - - if event_detail.is_some() { - // use timeout to avoid infinite loop - let resume = resume_watch.clone(); - let _ = set_timeout_with_handle(resume, Duration::ZERO); - } else { - resume_watch(); - } - }) - }; - - let update_from_custom_event = { - let update = Rc::clone(&update); - - move |event: web_sys::CustomEvent| { - let update = Rc::clone(&update); - queue_microtask(move || update(Some(event.into()))) - } - }; - - let update_from_storage_event = { - let update = Rc::clone(&update); - - move |event: web_sys::StorageEvent| update(Some(event.into())) - }; - - if listen_to_storage_changes { - let _ = use_event_listener(window(), ev::storage, update_from_storage_event); - let _ = use_event_listener( - window(), - ev::Custom::new(CUSTOM_STORAGE_EVENT_NAME), - update_from_custom_event, - ); - } - - update(None); - - let k = key.to_string(); - - Rc::new(move || { - let _ = storage.remove_item(&k); + // Schedules a storage event microtask. Uses a queue to avoid re-entering the runtime + let dispatch_storage_event = { + let key = key.as_ref().to_owned(); + let on_error = on_error.to_owned(); + move || { + let key = key.to_owned(); + let on_error = on_error.to_owned(); + queue_microtask(move || { + // Note: we cannot construct a full StorageEvent so we _must_ rely on a custom event + let mut custom = web_sys::CustomEventInit::new(); + custom.detail(&JsValue::from_str(&key)); + let result = window() + .dispatch_event( + &web_sys::CustomEvent::new_with_event_init_dict( + INTERNAL_STORAGE_EVENT, + &custom, + ) + .expect("failed to create custom storage event"), + ) + .map_err(UseStorageError::NotifyItemChangedFailed); + let _ = handle_error(&on_error, result); }) } - Err(e) => { - on_error(UseStorageError::NoStorage(e)); - Rc::new(move || {}) - } - _ => { - // do nothing - Rc::new(move || {}) + }; + + // Fetches direct from browser storage and fills set_data if changed (memo) + let fetch_from_storage = { + let storage = storage.to_owned(); + let codec = codec.to_owned(); + let key = key.as_ref().to_owned(); + let on_error = on_error.to_owned(); + move || { + let fetched = storage + .to_owned() + .and_then(|storage| { + // Get directly from storage + let result = storage + .get_item(&key) + .map_err(UseStorageError::GetItemFailed); + handle_error(&on_error, result) + }) + .unwrap_or_default() // Drop handled Err(()) + .map(|encoded| { + // Decode item + let result = codec + .decode(encoded) + .map_err(UseStorageError::ItemCodecError); + handle_error(&on_error, result) + }) + .transpose() + .unwrap_or_default(); // Drop handled Err(()) + + match fetched { + Some(value) => { + // Replace data if changed + if value != data.get_untracked() { + set_data.set(value) + } + } + + // Revert to default + None => set_data.set(default.clone()), + }; } }; + + // Fetch initial value + fetch_from_storage(); + + // Fires when storage needs to be fetched + let notify = create_trigger(); + + // Refetch from storage. Keeps track of how many times we've been notified. Does not increment for calls to set_data + let notify_id = create_memo::(move |prev| { + notify.track(); + match prev { + None => 1, // Avoid async fetch of initial value + Some(prev) => { + fetch_from_storage(); + prev + 1 + } + } + }); + + // Set item on internal (non-event) page changes to the data signal + { + let storage = storage.to_owned(); + let codec = codec.to_owned(); + let key = key.as_ref().to_owned(); + let on_error = on_error.to_owned(); + let dispatch_storage_event = dispatch_storage_event.to_owned(); + let _ = watch_with_options( + move || (notify_id.get(), data.get()), + move |(id, value), prev, _| { + // Skip setting storage on changes from external events. The ID will change on external events. + if prev.map(|(prev_id, _)| *prev_id != *id).unwrap_or_default() { + return; + } + + if let Ok(storage) = &storage { + // Encode value + let result = codec + .encode(value) + .map_err(UseStorageError::ItemCodecError) + .and_then(|enc_value| { + // Set storage -- sends a global event + storage + .set_item(&key, &enc_value) + .map_err(UseStorageError::SetItemFailed) + }); + let result = handle_error(&on_error, result); + // Send internal storage event + if result.is_ok() { + dispatch_storage_event(); + } + } + }, + WatchOptions::default().filter(filter), + ); + } + + if listen_to_storage_changes { + let check_key = key.as_ref().to_owned(); + // Listen to global storage events + let _ = use_event_listener(use_window(), leptos::ev::storage, move |ev| { + let ev_key = ev.key(); + // Key matches or all keys deleted (None) + if ev_key == Some(check_key.clone()) || ev_key.is_none() { + notify.notify() + } + }); + // Listen to internal storage events + let check_key = key.as_ref().to_owned(); + let _ = use_event_listener( + use_window(), + ev::Custom::new(INTERNAL_STORAGE_EVENT), + move |ev: web_sys::CustomEvent| { + if Some(check_key.clone()) == ev.detail().as_string() { + notify.notify() + } + }, + ); + }; + + // Remove from storage fn + let remove = { + let key = key.as_ref().to_owned(); + move || { + let _ = storage.as_ref().map(|storage| { + // Delete directly from storage + let result = storage + .remove_item(&key) + .map_err(UseStorageError::RemoveItemFailed); + let _ = handle_error(&on_error, result); + notify.notify(); + dispatch_storage_event(); + }); + } + }; + + (data, set_data, remove) }} - - (data, set_data, move || remove()) } -#[derive(Clone)] -pub struct StorageEventDetail { - pub key: Option, - pub old_value: Option, - pub new_value: Option, - pub storage_area: Option, +/// Session handling errors returned by [`use_storage_with_options`]. +#[derive(Error, Debug)] +pub enum UseStorageError { + #[error("storage not available")] + StorageNotAvailable(JsValue), + #[error("storage not returned from window")] + StorageReturnedNone, + #[error("failed to get item")] + GetItemFailed(JsValue), + #[error("failed to set item")] + SetItemFailed(JsValue), + #[error("failed to delete item")] + RemoveItemFailed(JsValue), + #[error("failed to notify item changed")] + NotifyItemChangedFailed(JsValue), + #[error("failed to encode / decode item value")] + ItemCodecError(Err), } -impl From for StorageEventDetail { - fn from(event: web_sys::StorageEvent) -> Self { - Self { - key: event.key(), - old_value: event.old_value(), - new_value: event.new_value(), - storage_area: event.storage_area(), - } - } +/// Options for use with [`use_local_storage_with_options`], [`use_session_storage_with_options`] and [`use_storage_with_options`]. +pub struct UseStorageOptions> { + // Translates to and from UTF-16 strings + codec: C, + // Callback for when an error occurs + on_error: Rc)>, + // Whether to continuously listen to changes from browser storage + listen_to_storage_changes: bool, + // Initial value to use when the storage key is not set + initial_value: MaybeRwSignal, + // Debounce or throttle the writing to storage whenever the value changes + filter: FilterOptions, } -impl From for StorageEventDetail { - fn from(event: web_sys::CustomEvent) -> Self { - let detail = event.detail(); - Self { - key: get_optional_string(&detail, "key"), - old_value: get_optional_string(&detail, "oldValue"), - new_value: get_optional_string(&detail, "newValue"), - storage_area: Reflect::get(&detail, &"storageArea".into()) - .map(|v| v.dyn_into::().ok()) - .unwrap_or_default(), - } - } +/// A codec for encoding and decoding values to and from UTF-16 strings. These strings are intended to be stored in browser storage. +/// +/// ## Versioning +/// +/// Versioning is the process of handling long-term data that can outlive our code. +/// +/// For example we could have a settings struct whose members change over time. We might eventually add timezone support and we might then remove support for a thousand separator on numbers. Each change results in a new possible version of the stored data. If we stored these settings in browser storage we would need to handle all possible versions of the data format that can occur. If we don't offer versioning then all settings could revert to the default every time we encounter an old format. +/// +/// How best to handle versioning depends on the codec involved: +/// +/// - The [`StringCodec`](super::StringCodec) can avoid versioning entirely by keeping to privimitive types. In our example above, we could have decomposed the settings struct into separate timezone and number separator fields. These would be encoded as strings and stored as two separate key-value fields in the browser rather than a single field. If a field is missing then the value intentionally would fallback to the default without interfering with the other field. +/// +/// - The [`ProstCodec`](super::ProstCodec) uses [Protocol buffers](https://protobuf.dev/overview/) designed to solve the problem of long-term storage. It provides semantics for versioning that are not present in JSON or other formats. +/// +/// - The [`JsonCodec`](super::JsonCodec) stores data as JSON. We can then rely on serde or by providing our own manual version handling. See the codec for more details. +pub trait Codec: Clone + 'static { + /// The error type returned when encoding or decoding fails. + type Error; + /// Encodes a value to a UTF-16 string. + fn encode(&self, val: &T) -> Result; + /// Decodes a UTF-16 string to a value. Should be able to decode any string encoded by [`encode`]. + fn decode(&self, str: String) -> Result; } -impl From for JsValue { - fn from(event: StorageEventDetail) -> Self { - let obj = js_sys::Object::new(); - - let _ = Reflect::set(&obj, &"key".into(), &event.key.into()); - let _ = Reflect::set(&obj, &"oldValue".into(), &event.old_value.into()); - let _ = Reflect::set(&obj, &"newValue".into(), &event.new_value.into()); - let _ = Reflect::set(&obj, &"storageArea".into(), &event.storage_area.into()); - - obj.into() - } +/// Calls the on_error callback with the given error. Removes the error from the Result to avoid double error handling. +#[cfg(not(feature = "ssr"))] +fn handle_error( + on_error: &Rc)>, + result: Result>, +) -> Result { + result.map_err(|err| (on_error)(err)) } -fn get_optional_string(v: &JsValue, key: &str) -> Option { - Reflect::get(v, &key.into()) - .map(|v| v.as_string()) - .unwrap_or_default() -} - -/// Error type for use_storage_with_options -// #[doc(cfg(feature = "storage"))] -pub enum UseStorageError { - NoStorage(JsValue), - StorageAccessError(JsValue), - CustomStorageAccessError(E), - SerializationError(Error), - DefaultSerializationError(Error), -} - -/// Options for [`use_storage_with_options`]. -// #[doc(cfg(feature = "storage"))] -#[derive(DefaultBuilder)] -pub struct UseStorageOptions { - /// Type of storage. Can be `Local` (default), `Session` or `Custom(web_sys::Storage)` - pub(crate) storage_type: StorageType, - /// Listen to changes to this storage key from somewhere else. Defaults to true. - pub(crate) listen_to_storage_changes: bool, - /// If no value for the give key is found in the storage, write it. Defaults to true. - pub(crate) write_defaults: bool, - /// Takes the serialized (json) stored value and the default value and returns a merged version. - /// Defaults to simply returning the stored value. - pub(crate) merge_defaults: fn(&str, &T) -> String, - /// Optional callback whenever an error occurs. The callback takes an argument of type [`UseStorageError`]. - pub(crate) on_error: Rc, - - /// Debounce or throttle the writing to storage whenever the value changes. - pub(crate) filter: FilterOptions, -} - -impl Default for UseStorageOptions { +impl + Default> Default for UseStorageOptions { fn default() -> Self { Self { - storage_type: Default::default(), + codec: C::default(), + on_error: Rc::new(|_err| ()), listen_to_storage_changes: true, - write_defaults: true, - merge_defaults: |stored_value, _default_value| stored_value.to_string(), - on_error: Rc::new(|_| ()), - filter: Default::default(), + initial_value: MaybeRwSignal::default(), + filter: FilterOptions::default(), } } } -impl UseStorageOptions { - filter_builder_methods!( - /// the serializing and storing into storage - filter - ); +impl> UseStorageOptions { + /// Sets the codec to use for encoding and decoding values to and from UTF-16 strings. + pub fn codec(self, codec: impl Into) -> Self { + Self { + codec: codec.into(), + ..self + } + } + + /// Optional callback whenever an error occurs. + pub fn on_error(self, on_error: impl Fn(UseStorageError) + 'static) -> Self { + Self { + on_error: Rc::new(on_error), + ..self + } + } + + /// Listen to changes to this storage key from browser and page events. Defaults to true. + pub fn listen_to_storage_changes(self, listen_to_storage_changes: bool) -> Self { + Self { + listen_to_storage_changes, + ..self + } + } + + /// Initial value to use when the storage key is not set. Note that this value is read once on creation of the storage hook and not updated again. Accepts a signal and defaults to `T::default()`. + pub fn initial_value(self, initial: impl Into>) -> Self { + Self { + initial_value: initial.into(), + ..self + } + } + + /// Debounce or throttle the writing to storage whenever the value changes. + pub fn filter(self, filter: impl Into) -> Self { + Self { + filter: filter.into(), + ..self + } + } } diff --git a/src/use_active_element.rs b/src/use_active_element.rs index 4a671b0..76da95a 100644 --- a/src/use_active_element.rs +++ b/src/use_active_element.rs @@ -1,6 +1,6 @@ #![cfg_attr(feature = "ssr", allow(unused_variables, unused_imports))] -use crate::{use_document, use_event_listener_with_options, UseEventListenerOptions}; +use crate::{use_document, use_event_listener_with_options, use_window, UseEventListenerOptions}; use leptos::ev::{blur, focus}; use leptos::html::{AnyElement, ToHtmlElement}; use leptos::*; @@ -45,7 +45,7 @@ pub fn use_active_element() -> Signal>> { let listener_options = UseEventListenerOptions::default().capture(true); let _ = use_event_listener_with_options( - window(), + use_window(), blur, move |event| { if event.related_target().is_some() { @@ -58,7 +58,7 @@ pub fn use_active_element() -> Signal>> { ); let _ = use_event_listener_with_options( - window(), + use_window(), focus, move |_| { set_active_element.update(|el| *el = get_active_element()); diff --git a/src/use_breakpoints.rs b/src/use_breakpoints.rs index 578c15f..d05840f 100644 --- a/src/use_breakpoints.rs +++ b/src/use_breakpoints.rs @@ -1,4 +1,4 @@ -use crate::use_media_query; +use crate::{use_media_query, use_window}; use leptos::logging::error; use leptos::*; use paste::paste; @@ -185,7 +185,7 @@ macro_rules! impl_cmp_reactively { impl UseBreakpointsReturn { fn match_(query: &str) -> bool { - if let Ok(Some(query_list)) = window().match_media(query) { + if let Ok(Some(query_list)) = use_window().match_media(query) { return query_list.matches(); } diff --git a/src/use_color_mode.rs b/src/use_color_mode.rs index 3bffcb5..52e829d 100644 --- a/src/use_color_mode.rs +++ b/src/use_color_mode.rs @@ -1,13 +1,10 @@ use crate::core::{ElementMaybeSignal, MaybeRwSignal}; -#[cfg(feature = "storage")] -use crate::storage::{use_storage_with_options, UseStorageOptions}; -#[cfg(feature = "storage")] -use serde::{Deserialize, Serialize}; +use crate::storage::{use_storage_with_options, StringCodec, UseStorageOptions}; use std::fmt::{Display, Formatter}; +use std::str::FromStr; use crate::core::StorageType; use crate::use_preferred_dark; -use cfg_if::cfg_if; use default_struct_builder::DefaultBuilder; use leptos::*; use std::marker::PhantomData; @@ -16,9 +13,6 @@ use wasm_bindgen::JsCast; /// Reactive color mode (dark / light / customs) with auto data persistence. /// -/// > Data persistence is only enabled when the crate feature **`storage`** is enabled. You -/// can use the function without it but the mode won't be persisted. -/// /// ## Demo /// /// [Link to Demo](https://github.com/Synphonyte/leptos-use/tree/main/examples/use_color_mode) @@ -27,7 +21,7 @@ use wasm_bindgen::JsCast; /// /// ``` /// # use leptos::*; -/// use leptos_use::{use_color_mode, UseColorModeReturn}; +/// # use leptos_use::{use_color_mode, UseColorModeReturn}; /// # /// # #[component] /// # fn Demo() -> impl IntoView { @@ -50,7 +44,7 @@ use wasm_bindgen::JsCast; /// /// ``` /// # use leptos::*; -/// use leptos_use::{ColorMode, use_color_mode, UseColorModeReturn}; +/// # use leptos_use::{ColorMode, use_color_mode, UseColorModeReturn}; /// # /// # #[component] /// # fn Demo() -> impl IntoView { @@ -58,7 +52,7 @@ use wasm_bindgen::JsCast; /// # /// mode.get(); // ColorMode::Dark or ColorMode::Light /// -/// set_mode.set(ColorMode::Dark); // change to dark mode and persist (with feature `storage`) +/// set_mode.set(ColorMode::Dark); // change to dark mode and persist /// /// set_mode.set(ColorMode::Auto); // change to auto mode /// # @@ -70,7 +64,7 @@ use wasm_bindgen::JsCast; /// /// ``` /// # use leptos::*; -/// use leptos_use::{use_color_mode_with_options, UseColorModeOptions, UseColorModeReturn}; +/// # use leptos_use::{use_color_mode_with_options, UseColorModeOptions, UseColorModeReturn}; /// # /// # #[component] /// # fn Demo() -> impl IntoView { @@ -160,7 +154,7 @@ where } }); - let target = (target).into(); + let target = target.into(); let update_html_attrs = { move |target: ElementMaybeSignal, @@ -255,49 +249,30 @@ pub enum ColorMode { Custom(String), } -cfg_if! { if #[cfg(feature = "storage")] { - fn get_store_signal( - initial_value: MaybeRwSignal, - storage_signal: Option>, - storage_key: &str, - storage_enabled: bool, - storage: StorageType, - listen_to_storage_changes: bool, - ) -> (Signal, WriteSignal) { - if let Some(storage_signal) = storage_signal { - let (store, set_store) = storage_signal.split(); - (store.into(), set_store) - } else if storage_enabled { - let (store, set_store, _) = use_storage_with_options( - storage_key, - initial_value, - UseStorageOptions::default() - .listen_to_storage_changes(listen_to_storage_changes) - .storage_type(storage), - ); - - (store, set_store) - } else { - initial_value.into_signal() - } +fn get_store_signal( + initial_value: MaybeRwSignal, + storage_signal: Option>, + storage_key: &str, + storage_enabled: bool, + storage: StorageType, + listen_to_storage_changes: bool, +) -> (Signal, WriteSignal) { + if let Some(storage_signal) = storage_signal { + let (store, set_store) = storage_signal.split(); + (store.into(), set_store) + } else if storage_enabled { + let (store, set_store, _) = use_storage_with_options::( + storage, + storage_key, + UseStorageOptions::default() + .listen_to_storage_changes(listen_to_storage_changes) + .initial_value(initial_value), + ); + (store, set_store) + } else { + initial_value.into_signal() } -} else { - fn get_store_signal( - initial_value: MaybeRwSignal, - storage_signal: Option>, - _storage_key: &str, - _storage_enabled: bool, - _storage: StorageType, - _listen_to_storage_changes: bool, - ) -> (Signal, WriteSignal) { - if let Some(storage_signal) = storage_signal { - let (store, set_store) = storage_signal.split(); - (store.into(), set_store) - } else { - initial_value.into_signal() - } - } -}} +} impl Display for ColorMode { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { @@ -330,6 +305,14 @@ impl From for ColorMode { } } +impl FromStr for ColorMode { + type Err = (); + + fn from_str(s: &str) -> Result { + Ok(ColorMode::from(s)) + } +} + #[derive(DefaultBuilder)] pub struct UseColorModeOptions where @@ -378,7 +361,7 @@ where /// If the color mode should be persisted. If `true` this required the /// *create feature* **`storage`** to be enabled. - /// Defaults to `true` and is forced to `false` if the feature **`storage`** is not enabled. + /// Defaults to `true`. storage_enabled: bool, /// Emit `auto` mode from state diff --git a/src/use_css_var.rs b/src/use_css_var.rs index 41f1d04..0b0a803 100644 --- a/src/use_css_var.rs +++ b/src/use_css_var.rs @@ -105,7 +105,7 @@ where let (variable, set_variable) = create_signal(initial_value.clone()); cfg_if! { if #[cfg(not(feature = "ssr"))] { - let el_signal = (target).into(); + let el_signal = target.into(); let prop = prop.into(); let update_css_var = { diff --git a/src/use_display_media.rs b/src/use_display_media.rs new file mode 100644 index 0000000..fb0ff8c --- /dev/null +++ b/src/use_display_media.rs @@ -0,0 +1,191 @@ +use crate::core::MaybeRwSignal; +use cfg_if::cfg_if; +use default_struct_builder::DefaultBuilder; +use leptos::*; +use wasm_bindgen::{JsCast, JsValue}; + +/// Reactive [`mediaDevices.getDisplayMedia`](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia) streaming. +/// +/// ## Demo +/// +/// [Link to Demo](https://github.com/Synphonyte/leptos-use/tree/main/examples/use_display_media) +/// +/// ## Usage +/// +/// ``` +/// # use leptos::*; +/// # use leptos_use::{use_display_media, UseDisplayMediaReturn}; +/// # +/// # #[component] +/// # fn Demo() -> impl IntoView { +/// let video_ref = create_node_ref::(); +/// +/// let UseDisplayMediaReturn { stream, start, .. } = use_display_media(); +/// +/// start(); +/// +/// create_effect(move |_| +/// video_ref.get().map(|v| { +/// match stream.get() { +/// Some(Ok(s)) => v.set_src_object(Some(&s)), +/// Some(Err(e)) => logging::error!("Failed to get media stream: {:?}", e), +/// None => logging::log!("No stream yet"), +/// } +/// }) +/// ); +/// +/// view! { } +/// # } +/// ``` +/// +/// ## Server-Side Rendering +/// +/// On the server calls to `start` or any other way to enable the stream will be ignored +/// and the stream will always be `None`. +pub fn use_display_media() -> UseDisplayMediaReturn { + use_display_media_with_options(UseDisplayMediaOptions::default()) +} + +/// Version of [`use_display_media`] that accepts a [`UseDisplayMediaOptions`]. +pub fn use_display_media_with_options( + options: UseDisplayMediaOptions, +) -> UseDisplayMediaReturn { + let UseDisplayMediaOptions { enabled, audio } = options; + + let (enabled, set_enabled) = enabled.into_signal(); + + let (stream, set_stream) = create_signal(None::>); + + let _start = move || async move { + cfg_if! { if #[cfg(not(feature = "ssr"))] { + if stream.get_untracked().is_some() { + return; + } + + let stream = create_media(audio).await; + + set_stream.update(|s| *s = Some(stream)); + } else { + let _ = audio; + }} + }; + + let _stop = move || { + if let Some(Ok(stream)) = stream.get_untracked() { + for track in stream.get_tracks() { + track.unchecked_ref::().stop(); + } + } + + set_stream.set(None); + }; + + let start = move || { + cfg_if! { if #[cfg(not(feature = "ssr"))] { + spawn_local(async move { + _start().await; + stream.with_untracked(move |stream| { + if let Some(Ok(_)) = stream { + set_enabled.set(true); + } + }); + }); + }} + }; + + let stop = move || { + _stop(); + set_enabled.set(false); + }; + + let _ = watch( + move || enabled.get(), + move |enabled, _, _| { + if *enabled { + spawn_local(async move { + _start().await; + }); + } else { + _stop(); + } + }, + true, + ); + + UseDisplayMediaReturn { + stream: stream.into(), + start, + stop, + enabled, + set_enabled, + } +} + +#[cfg(not(feature = "ssr"))] +async fn create_media(audio: bool) -> Result { + use crate::use_window::use_window; + + let media = use_window() + .navigator() + .ok_or_else(|| JsValue::from_str("Failed to access window.navigator")) + .and_then(|n| n.media_devices())?; + + let mut constraints = web_sys::DisplayMediaStreamConstraints::new(); + if audio { + constraints.audio(&JsValue::from(true)); + } + + let promise = media.get_display_media_with_constraints(&constraints)?; + let res = wasm_bindgen_futures::JsFuture::from(promise).await?; + + Ok::<_, JsValue>(web_sys::MediaStream::unchecked_from_js(res)) +} + +// NOTE: there's no video value because it has to be `true`. Otherwise the stream would always resolve to an Error. +/// Options for [`use_display_media`]. +#[derive(DefaultBuilder, Clone, Copy, Debug)] +pub struct UseDisplayMediaOptions { + /// If the stream is enabled. Defaults to `false`. + enabled: MaybeRwSignal, + + /// A value of `true` indicates that the returned [`MediaStream`](https://developer.mozilla.org/en-US/docs/Web/API/MediaStream) + /// will contain an audio track, if audio is supported and available for the display surface chosen by the user. + /// The default value is `false`. + audio: bool, +} + +impl Default for UseDisplayMediaOptions { + fn default() -> Self { + Self { + enabled: false.into(), + audio: false, + } + } +} + +/// Return type of [`use_display_media`] +#[derive(Clone)] +pub struct UseDisplayMediaReturn +where + StartFn: Fn() + Clone, + StopFn: Fn() + Clone, +{ + /// The current [`MediaStream`](https://developer.mozilla.org/en-US/docs/Web/API/MediaStream) if it exists. + /// Initially this is `None` until `start` resolved successfully. + /// In case the stream couldn't be started, for example because the user didn't grant permission, + /// this has the value `Some(Err(...))`. + pub stream: Signal>>, + + /// Starts the screen streaming. Triggers the ask for permission if not already granted. + pub start: StartFn, + + /// Stops the screen streaming + pub stop: StopFn, + + /// A value of `true` indicates that the returned [`MediaStream`](https://developer.mozilla.org/en-US/docs/Web/API/MediaStream) + /// has resolved successfully and thus the stream is enabled. + pub enabled: Signal, + + /// A value of `true` is the same as calling `start()` whereas `false` is the same as calling `stop()`. + pub set_enabled: WriteSignal, +} diff --git a/src/use_document.rs b/src/use_document.rs index 2a7a1bb..d9afaac 100644 --- a/src/use_document.rs +++ b/src/use_document.rs @@ -1,6 +1,7 @@ use cfg_if::cfg_if; use std::ops::Deref; +use crate::core::impl_ssr_safe_method; #[cfg(not(feature = "ssr"))] use leptos::*; @@ -46,11 +47,15 @@ impl Deref for UseDocument { } impl UseDocument { - pub fn body(&self) -> Option { - self.0.as_ref().and_then(|d| d.body()) - } + impl_ssr_safe_method!( + /// Returns `Some(Document)` in the Browser. `None` otherwise. + body(&self) -> Option; + .unwrap_or_default() + ); - pub fn active_element(&self) -> Option { - self.0.as_ref().and_then(|d| d.active_element()) - } + impl_ssr_safe_method!( + /// Returns the active (focused) `Some(web_sys::Element)` in the Browser. `None` otherwise. + active_element(&self) -> Option; + .unwrap_or_default() + ); } diff --git a/src/use_draggable.rs b/src/use_draggable.rs index b293626..c44aef8 100644 --- a/src/use_draggable.rs +++ b/src/use_draggable.rs @@ -1,5 +1,5 @@ use crate::core::{ElementMaybeSignal, MaybeRwSignal, PointerType, Position}; -use crate::{use_event_listener_with_options, UseEventListenerOptions}; +use crate::{use_event_listener_with_options, use_window, UseEventListenerOptions, UseWindow}; use default_struct_builder::DefaultBuilder; use leptos::ev::{pointerdown, pointermove, pointerup}; use leptos::*; @@ -52,8 +52,8 @@ where use_draggable_with_options::< El, T, - web_sys::EventTarget, - web_sys::EventTarget, + UseWindow, + web_sys::Window, web_sys::EventTarget, web_sys::EventTarget, >(target, UseDraggableOptions::default()) @@ -87,7 +87,7 @@ where .. } = options; - let target = (target).into(); + let target = target.into(); let dragging_handle = if let Some(handle) = handle { let handle = (handle).into(); @@ -267,19 +267,14 @@ where } impl Default - for UseDraggableOptions< - web_sys::EventTarget, - web_sys::EventTarget, - web_sys::EventTarget, - web_sys::EventTarget, - > + for UseDraggableOptions { fn default() -> Self { Self { exact: MaybeSignal::default(), prevent_default: MaybeSignal::default(), stop_propagation: MaybeSignal::default(), - dragging_element: window().into(), + dragging_element: use_window(), handle: None, pointer_types: vec![PointerType::Mouse, PointerType::Touch, PointerType::Pen], initial_value: MaybeRwSignal::default(), diff --git a/src/use_element_size.rs b/src/use_element_size.rs index 8831505..bc6a226 100644 --- a/src/use_element_size.rs +++ b/src/use_element_size.rs @@ -12,9 +12,6 @@ cfg_if! { if #[cfg(not(feature = "ssr"))] { /// Reactive size of an HTML element. /// -/// > This function requires `--cfg=web_sys_unstable_apis` to be activated as -/// [described in the wasm-bindgen guide](https://rustwasm.github.io/docs/wasm-bindgen/web-sys/unstable-apis.html). -/// /// Please refer to [ResizeObserver on MDN](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) /// for more details. /// @@ -25,12 +22,12 @@ cfg_if! { if #[cfg(not(feature = "ssr"))] { /// ## Usage /// /// ``` -/// # use leptos::*; +/// # use leptos::{html::Div, *}; /// # use leptos_use::{use_element_size, UseElementSizeReturn}; /// # /// # #[component] /// # fn Demo() -> impl IntoView { -/// let el = create_node_ref(); +/// let el = create_node_ref::
(); /// /// let UseElementSizeReturn { width, height } = use_element_size(el); /// @@ -175,7 +172,7 @@ where } } -#[derive(DefaultBuilder)] +#[derive(DefaultBuilder, Default)] /// Options for [`use_element_size_with_options`]. pub struct UseElementSizeOptions { /// Initial size returned before any measurements on the `target` are done. Also the value reported @@ -187,15 +184,6 @@ pub struct UseElementSizeOptions { pub box_: Option, } -impl Default for UseElementSizeOptions { - fn default() -> Self { - Self { - initial_size: Size::default(), - box_: None, - } - } -} - /// The return value of [`use_element_size`]. pub struct UseElementSizeReturn { /// The width of the element. diff --git a/src/use_event_listener.rs b/src/use_event_listener.rs index e8c218e..ec7c2a8 100644 --- a/src/use_event_listener.rs +++ b/src/use_event_listener.rs @@ -132,7 +132,7 @@ where let event_name = event.name(); - let signal = (target).into(); + let signal = target.into(); let prev_element = Rc::new(RefCell::new(None::)); diff --git a/src/use_idle.rs b/src/use_idle.rs index 247c867..3f0a20e 100644 --- a/src/use_idle.rs +++ b/src/use_idle.rs @@ -1,14 +1,9 @@ -use crate::utils::{create_filter_wrapper, DebounceOptions, FilterOptions, ThrottleOptions}; -use crate::{ - filter_builder_methods, use_document, use_event_listener, use_event_listener_with_options, - UseEventListenerOptions, -}; +use crate::core::now; +use crate::filter_builder_methods; +use crate::utils::{DebounceOptions, FilterOptions, ThrottleOptions}; +use cfg_if::cfg_if; use default_struct_builder::DefaultBuilder; -use leptos::ev::{visibilitychange, Custom}; -use leptos::leptos_dom::helpers::TimeoutHandle; use leptos::*; -use std::cell::Cell; -use std::time::Duration; /// /// @@ -54,6 +49,18 @@ use std::time::Duration; /// # view! { } /// # } /// ``` +/// +/// ## Server-Side Rendering +/// +/// On the server this will always return static signals +/// +/// ```ignore +/// UseIdleReturn{ +/// idle: Signal(initial_state), +/// last_active: Signal(now), +/// reset: || {} +/// } +/// ``` pub fn use_idle(timeout: u64) -> UseIdleReturn { use_idle_with_options(timeout, UseIdleOptions::default()) } @@ -71,57 +78,78 @@ pub fn use_idle_with_options( } = options; let (idle, set_idle) = create_signal(initial_state); - let (last_active, set_last_active) = create_signal(js_sys::Date::now()); + let (last_active, set_last_active) = create_signal(now()); - let reset = { - let timer = Cell::new(None::); + cfg_if! { if #[cfg(feature = "ssr")] { + let reset = || (); + let _ = timeout; + let _ = events; + let _ = listen_for_visibility_change; + let _ = filter; + let _ = set_last_active; + let _ = set_idle; + } else { + use crate::utils::create_filter_wrapper; + use crate::{ + use_document, use_event_listener, use_event_listener_with_options, UseEventListenerOptions, + }; + use leptos::ev::{visibilitychange, Custom}; + use leptos::leptos_dom::helpers::TimeoutHandle; + use std::cell::Cell; + use std::rc::Rc; + use std::time::Duration; - move || { - set_idle.set(false); - if let Some(timer) = timer.take() { - timer.clear(); + let timer = Rc::new(Cell::new(None::)); + + let reset = { + let timer = Rc::clone(&timer); + + move || { + set_idle.set(false); + if let Some(timer) = timer.replace( + set_timeout_with_handle(move || set_idle.set(true), Duration::from_millis(timeout)) + .ok(), + ) { + timer.clear(); + } } - timer.replace( - set_timeout_with_handle(move || set_idle.set(true), Duration::from_millis(timeout)) - .ok(), + }; + + let on_event = { + let reset = reset.clone(); + + let filtered_callback = create_filter_wrapper(filter.filter_fn(), move || { + set_last_active.set(js_sys::Date::now()); + reset(); + }); + + move |_: web_sys::Event| { + filtered_callback(); + } + }; + + let listener_options = UseEventListenerOptions::default().passive(true); + for event in events { + let _ = use_event_listener_with_options( + use_document(), + Custom::new(event), + on_event.clone(), + listener_options, ); } - }; - let on_event = { - let reset = reset.clone(); + if listen_for_visibility_change { + let on_event = on_event.clone(); - let filtered_callback = create_filter_wrapper(filter.filter_fn(), move || { - set_last_active.set(js_sys::Date::now()); - reset(); - }); - - move |_: web_sys::Event| { - filtered_callback(); + let _ = use_event_listener(use_document(), visibilitychange, move |evt| { + if !document().hidden() { + on_event(evt); + } + }); } - }; - let listener_options = UseEventListenerOptions::default().passive(true); - for event in events { - let _ = use_event_listener_with_options( - use_document(), - Custom::new(event), - on_event.clone(), - listener_options, - ); - } - - if listen_for_visibility_change { - let on_event = on_event.clone(); - - let _ = use_event_listener(use_document(), visibilitychange, move |evt| { - if !document().hidden() { - on_event(evt); - } - }); - } - - reset.clone()(); + reset.clone()(); + }} UseIdleReturn { idle: idle.into(), diff --git a/src/use_infinite_scroll.rs b/src/use_infinite_scroll.rs new file mode 100644 index 0000000..2b23261 --- /dev/null +++ b/src/use_infinite_scroll.rs @@ -0,0 +1,255 @@ +use crate::core::{Direction, Directions, ElementMaybeSignal}; +use crate::{ + use_element_visibility, use_scroll_with_options, ScrollOffset, UseEventListenerOptions, + UseScrollOptions, UseScrollReturn, +}; +use default_struct_builder::DefaultBuilder; +use futures_util::join; +use gloo_timers::future::sleep; +use leptos::*; +use std::future::Future; +use std::rc::Rc; +use std::time::Duration; +use wasm_bindgen::JsCast; + +/// Infinite scrolling of the element. +/// +/// ## Demo +/// +/// [Link to Demo](https://github.com/Synphonyte/leptos-use/tree/main/examples/use_infinite_scroll) +/// +/// ## Usage +/// +/// ``` +/// # use leptos::*; +/// use leptos::html::Div; +/// # use leptos_use::{use_infinite_scroll_with_options, UseInfiniteScrollOptions}; +/// # +/// # #[component] +/// # fn Demo() -> impl IntoView { +/// let el = create_node_ref::
(); +/// +/// let (data, set_data) = create_signal(vec![1, 2, 3, 4, 5, 6]); +/// +/// let _ = use_infinite_scroll_with_options( +/// el, +/// move |_| async move { +/// let len = data.with(|d| d.len()); +/// set_data.update(|data| *data = (1..len+6).collect()); +/// }, +/// UseInfiniteScrollOptions::default().distance(10.0), +/// ); +/// +/// view! { +///
+/// { item } +///
+/// } +/// # } +/// ``` +/// +/// The returned signal is `true` while new data is being loaded. +pub fn use_infinite_scroll(el: El, on_load_more: LFn) -> Signal +where + El: Into> + Clone + 'static, + T: Into + Clone + 'static, + LFn: Fn(ScrollState) -> LFut + 'static, + LFut: Future, +{ + use_infinite_scroll_with_options(el, on_load_more, UseInfiniteScrollOptions::default()) +} + +/// Version of [`use_infinite_scroll`] that takes a `UseInfiniteScrollOptions`. See [`use_infinite_scroll`] for how to use. +pub fn use_infinite_scroll_with_options( + el: El, + on_load_more: LFn, + options: UseInfiniteScrollOptions, +) -> Signal +where + El: Into> + Clone + 'static, + T: Into + Clone + 'static, + LFn: Fn(ScrollState) -> LFut + 'static, + LFut: Future, +{ + let UseInfiniteScrollOptions { + distance, + direction, + interval, + on_scroll, + event_listener_options, + } = options; + + let on_load_more = store_value(on_load_more); + + let UseScrollReturn { + x, + y, + is_scrolling, + arrived_state, + directions, + measure, + .. + } = use_scroll_with_options( + el.clone(), + UseScrollOptions::default() + .on_scroll(move |evt| on_scroll(evt)) + .event_listener_options(event_listener_options) + .offset(ScrollOffset::default().set_direction(direction, distance)), + ); + + let state = ScrollState { + x, + y, + is_scrolling, + arrived_state, + directions, + }; + + let (is_loading, set_loading) = create_signal(false); + + let el = el.into(); + let observed_element = create_memo(move |_| { + let el = el.get(); + + el.map(|el| { + let el = el.into(); + + if el.is_instance_of::() || el.is_instance_of::() { + document() + .document_element() + .expect("document element not found") + } else { + el + } + }) + }); + + let is_element_visible = use_element_visibility(observed_element); + + let check_and_load = store_value(None::>); + + check_and_load.set_value(Some(Rc::new({ + let measure = measure.clone(); + + move || { + let observed_element = observed_element.get_untracked(); + + if !is_element_visible.get_untracked() { + return; + } + + if let Some(observed_element) = observed_element { + let scroll_height = observed_element.scroll_height(); + let client_height = observed_element.client_height(); + let scroll_width = observed_element.scroll_width(); + let client_width = observed_element.client_width(); + + let is_narrower = if direction == Direction::Bottom || direction == Direction::Top { + scroll_height <= client_height + } else { + scroll_width <= client_width + }; + + if (state.arrived_state.get_untracked().get_direction(direction) || is_narrower) + && !is_loading.get_untracked() + { + set_loading.set(true); + + let measure = measure.clone(); + spawn_local(async move { + join!( + on_load_more.with_value(|f| f(state)), + sleep(Duration::from_millis(interval as u64)) + ); + + set_loading.set(false); + sleep(Duration::ZERO).await; + measure(); + if let Some(check_and_load) = check_and_load.get_value() { + check_and_load(); + } + }); + } + } + } + }))); + + let _ = watch( + move || is_element_visible.get(), + move |visible, prev_visible, _| { + if *visible && !prev_visible.copied().unwrap_or_default() { + measure(); + } + }, + true, + ); + + let _ = watch( + move || state.arrived_state.get().get_direction(direction), + move |arrived, prev_arrived, _| { + if let Some(prev_arrived) = prev_arrived { + if prev_arrived == arrived { + return; + } + } + + check_and_load + .get_value() + .expect("check_and_load is set above")() + }, + true, + ); + + is_loading.into() +} + +/// Options for [`use_infinite_scroll_with_options`]. +#[derive(DefaultBuilder)] +pub struct UseInfiniteScrollOptions { + /// Callback when scrolling is happening. + on_scroll: Rc, + + /// Options passed to the `addEventListener("scroll", ...)` call + event_listener_options: UseEventListenerOptions, + + /// The minimum distance between the bottom of the element and the bottom of the viewport. Default is 0.0. + distance: f64, + + /// The direction in which to listen the scroll. Defaults to `Direction::Bottom`. + direction: Direction, + + /// The interval time between two load more (to avoid too many invokes). Default is 100.0. + interval: f64, +} + +impl Default for UseInfiniteScrollOptions { + fn default() -> Self { + Self { + on_scroll: Rc::new(|_| {}), + event_listener_options: Default::default(), + distance: 0.0, + direction: Direction::Bottom, + interval: 100.0, + } + } +} + +/// The scroll state being passed into the `on_load_more` callback of [`use_infinite_scroll`]. +#[derive(Copy, Clone)] +pub struct ScrollState { + /// X coordinate of scroll position + pub x: Signal, + + /// Y coordinate of scroll position + pub y: Signal, + + /// Is true while the element is being scrolled. + pub is_scrolling: Signal, + + /// Sets the field that represents a direction to true if the + /// element is scrolled all the way to that side. + pub arrived_state: Signal, + + /// The directions in which the element is being scrolled are set to true. + pub directions: Signal, +} diff --git a/src/use_intersection_observer.rs b/src/use_intersection_observer.rs index fda59bf..78c43fb 100644 --- a/src/use_intersection_observer.rs +++ b/src/use_intersection_observer.rs @@ -124,7 +124,7 @@ where } }; - let targets = (target).into(); + let targets = target.into(); let root = root.map(|root| (root).into()); let stop_watch = { diff --git a/src/use_mouse.rs b/src/use_mouse.rs index 5e4e17b..f81741a 100644 --- a/src/use_mouse.rs +++ b/src/use_mouse.rs @@ -1,7 +1,7 @@ #![cfg_attr(feature = "ssr", allow(unused_variables, unused_imports))] use crate::core::{ElementMaybeSignal, Position}; -use crate::{use_event_listener_with_options, UseEventListenerOptions}; +use crate::{use_event_listener_with_options, use_window, UseEventListenerOptions, UseWindow}; use cfg_if::cfg_if; use default_struct_builder::DefaultBuilder; use leptos::ev::{dragover, mousemove, touchend, touchmove, touchstart}; @@ -208,8 +208,7 @@ where /// Options for [`use_mouse_with_options`]. pub struct UseMouseOptions where - El: Clone, - El: Into>, + El: Clone + Into>, T: Into + Clone + 'static, Ex: UseMouseEventExtractor + Clone, { @@ -232,33 +231,18 @@ where _marker: PhantomData, } -cfg_if! { if #[cfg(feature = "ssr")] { - impl Default for UseMouseOptions, web_sys::Window, UseMouseEventExtractorDefault> { - fn default() -> Self { - Self { - coord_type: UseMouseCoordType::::default(), - target: None, - touch: true, - reset_on_touch_ends: false, - initial_value: Position { x: 0.0, y: 0.0 }, - _marker: Default::default(), - } +impl Default for UseMouseOptions { + fn default() -> Self { + Self { + coord_type: UseMouseCoordType::::default(), + target: use_window(), + touch: true, + reset_on_touch_ends: false, + initial_value: Position { x: 0.0, y: 0.0 }, + _marker: PhantomData, } } -} else { - impl Default for UseMouseOptions { - fn default() -> Self { - Self { - coord_type: UseMouseCoordType::::default(), - target: window(), - touch: true, - reset_on_touch_ends: false, - initial_value: Position { x: 0.0, y: 0.0 }, - _marker: Default::default(), - } - } - } -}} +} /// Defines how to get the coordinates from the event. #[derive(Clone)] diff --git a/src/use_mutation_observer.rs b/src/use_mutation_observer.rs index f518000..91f185a 100644 --- a/src/use_mutation_observer.rs +++ b/src/use_mutation_observer.rs @@ -109,7 +109,7 @@ where } }; - let targets = (target).into(); + let targets = target.into(); let stop_watch = { let cleanup = cleanup.clone(); diff --git a/src/use_raf_fn.rs b/src/use_raf_fn.rs index c44a00a..27c7389 100644 --- a/src/use_raf_fn.rs +++ b/src/use_raf_fn.rs @@ -1,10 +1,9 @@ use crate::utils::Pausable; +use cfg_if::cfg_if; use default_struct_builder::DefaultBuilder; use leptos::*; use std::cell::{Cell, RefCell}; use std::rc::Rc; -use wasm_bindgen::closure::Closure; -use wasm_bindgen::JsCast; /// Call function on every requestAnimationFrame. /// With controls of pausing and resuming. @@ -34,6 +33,10 @@ use wasm_bindgen::JsCast; /// /// You can use `use_raf_fn_with_options` and set `immediate` to `false`. In that case /// you have to call `resume()` before the `callback` is executed. +/// +/// ## Server-Side Rendering +/// +/// On the server this does basically nothing. The provided closure will never be called. pub fn use_raf_fn( callback: impl Fn(UseRafFnCallbackArgs) + 'static, ) -> Pausable { @@ -54,24 +57,31 @@ pub fn use_raf_fn_with_options( let loop_ref = Rc::new(RefCell::new(Box::new(|_: f64| {}) as Box)); let request_next_frame = { - let loop_ref = Rc::clone(&loop_ref); - let raf_handle = Rc::clone(&raf_handle); + cfg_if! { if #[cfg(feature = "ssr")] { + move || () + } else { + use wasm_bindgen::JsCast; + use wasm_bindgen::closure::Closure; - move || { let loop_ref = Rc::clone(&loop_ref); + let raf_handle = Rc::clone(&raf_handle); - raf_handle.set( - window() - .request_animation_frame( - Closure::once_into_js(move |timestamp: f64| { - loop_ref.borrow()(timestamp); - }) - .as_ref() - .unchecked_ref(), - ) - .ok(), - ); - } + move || { + let loop_ref = Rc::clone(&loop_ref); + + raf_handle.set( + window() + .request_animation_frame( + Closure::once_into_js(move |timestamp: f64| { + loop_ref.borrow()(timestamp); + }) + .as_ref() + .unchecked_ref(), + ) + .ok(), + ); + } + }} }; let loop_fn = { @@ -79,7 +89,7 @@ pub fn use_raf_fn_with_options( let previous_frame_timestamp = Cell::new(0.0_f64); move |timestamp: f64| { - if !is_active.get() { + if !is_active.get_untracked() { return; } @@ -101,7 +111,7 @@ pub fn use_raf_fn_with_options( let _ = loop_ref.replace(Box::new(loop_fn)); let resume = move || { - if !is_active.get() { + if !is_active.get_untracked() { set_active.set(true); request_next_frame(); } diff --git a/src/use_resize_observer.rs b/src/use_resize_observer.rs index 1c61081..07e5dd4 100644 --- a/src/use_resize_observer.rs +++ b/src/use_resize_observer.rs @@ -12,9 +12,6 @@ cfg_if! { if #[cfg(not(feature = "ssr"))] { /// Reports changes to the dimensions of an Element's content or the border-box. /// -/// > This function requires `--cfg=web_sys_unstable_apis` to be activated as -/// [described in the wasm-bindgen guide](https://rustwasm.github.io/docs/wasm-bindgen/web-sys/unstable-apis.html). -/// /// Please refer to [ResizeObserver on MDN](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) /// for more details. /// @@ -25,12 +22,12 @@ cfg_if! { if #[cfg(not(feature = "ssr"))] { /// ## Usage /// /// ``` -/// # use leptos::*; +/// # use leptos::{html::Div, *}; /// # use leptos_use::use_resize_observer; /// # /// # #[component] /// # fn Demo() -> impl IntoView { -/// let el = create_node_ref(); +/// let el = create_node_ref::
(); /// let (text, set_text) = create_signal("".to_string()); /// /// use_resize_observer( @@ -114,7 +111,7 @@ where } }; - let targets = (target).into(); + let targets = target.into(); let stop_watch = { let cleanup = cleanup.clone(); diff --git a/src/use_scroll.rs b/src/use_scroll.rs index 94c2c12..757c6dc 100644 --- a/src/use_scroll.rs +++ b/src/use_scroll.rs @@ -1,4 +1,4 @@ -use crate::core::ElementMaybeSignal; +use crate::core::{Direction, Directions, ElementMaybeSignal}; use crate::UseEventListenerOptions; use cfg_if::cfg_if; use default_struct_builder::DefaultBuilder; @@ -174,7 +174,9 @@ const ARRIVED_STATE_THRESHOLD_PIXELS: f64 = 1.0; /// ## Server-Side Rendering /// /// On the server this returns signals that don't change and setters that are noops. -pub fn use_scroll(element: El) -> UseScrollReturn +pub fn use_scroll( + element: El, +) -> UseScrollReturn where El: Clone, El: Into>, @@ -185,7 +187,10 @@ where /// Version of [`use_scroll`] with options. See [`use_scroll`] for how to use. #[cfg_attr(feature = "ssr", allow(unused_variables))] -pub fn use_scroll_with_options(element: El, options: UseScrollOptions) -> UseScrollReturn +pub fn use_scroll_with_options( + element: El, + options: UseScrollOptions, +) -> UseScrollReturn where El: Clone, El: Into>, @@ -210,9 +215,9 @@ where }); cfg_if! { if #[cfg(feature = "ssr")] { - let set_x = Box::new(|_| {}); - let set_y = Box::new(|_| {}); - let measure = Box::new(|| {}); + let set_x = |_| {}; + let set_y = |_| {}; + let measure = || {}; } else { let signal = element.into(); let behavior = options.behavior; @@ -243,16 +248,16 @@ where let set_x = { let scroll_to = scroll_to.clone(); - Box::new(move |x| scroll_to(Some(x), None)) + move |x| scroll_to(Some(x), None) }; - let set_y = Box::new(move |y| scroll_to(None, Some(y))); + let set_y = move |y| scroll_to(None, Some(y)); let on_scroll_end = { let on_stop = Rc::clone(&options.on_stop); move |e| { - if !is_scrolling.get_untracked() { + if !is_scrolling.try_get_untracked().unwrap_or_default() { return; } @@ -385,12 +390,7 @@ where Signal>, web_sys::EventTarget, _, - >( - target, - ev::scroll, - handler, - options.event_listener_options, - ); + >(target, ev::scroll, handler, options.event_listener_options); } else { let _ = use_event_listener_with_options::< _, @@ -417,13 +417,12 @@ where options.event_listener_options, ); - let measure = Box::new(move || { - let el = signal.get_untracked(); - if let Some(el) = el { + let measure = move || { + if let Some(el) = signal.get_untracked() { let el = el.into(); set_arrived_state(el); } - }); + }; }} UseScrollReturn { @@ -501,26 +500,39 @@ impl From for web_sys::ScrollBehavior { } /// The return value of [`use_scroll`]. -pub struct UseScrollReturn { +pub struct UseScrollReturn +where + SetXFn: Fn(f64) + Clone, + SetYFn: Fn(f64) + Clone, + MFn: Fn() + Clone, +{ + /// X coordinate of scroll position pub x: Signal, - pub set_x: Box, + + /// Sets the value of `x`. This does also scroll the element. + pub set_x: SetXFn, + + /// Y coordinate of scroll position pub y: Signal, - pub set_y: Box, + + /// Sets the value of `y`. This does also scroll the element. + pub set_y: SetYFn, + + /// Is true while the element is being scrolled. pub is_scrolling: Signal, + + /// Sets the field that represents a direction to true if the + /// element is scrolled all the way to that side. pub arrived_state: Signal, + + /// The directions in which the element is being scrolled are set to true. pub directions: Signal, - pub measure: Box, + + /// Re-evaluates the `arrived_state`. + pub measure: MFn, } -#[derive(Copy, Clone)] -pub struct Directions { - pub left: bool, - pub right: bool, - pub top: bool, - pub bottom: bool, -} - -#[derive(Default, Copy, Clone)] +#[derive(Default, Copy, Clone, Debug)] /// Threshold in pixels when we consider a side to have arrived (`UseScrollReturn::arrived_state`). pub struct ScrollOffset { pub left: f64, @@ -528,3 +540,17 @@ pub struct ScrollOffset { pub right: f64, pub bottom: f64, } + +impl ScrollOffset { + /// Sets the value of the provided direction + pub fn set_direction(mut self, direction: Direction, value: f64) -> Self { + match direction { + Direction::Top => self.top = value, + Direction::Bottom => self.bottom = value, + Direction::Left => self.left = value, + Direction::Right => self.right = value, + } + + self + } +} diff --git a/src/use_service_worker.rs b/src/use_service_worker.rs new file mode 100644 index 0000000..b9dda2d --- /dev/null +++ b/src/use_service_worker.rs @@ -0,0 +1,281 @@ +use default_struct_builder::DefaultBuilder; +use leptos::*; +use std::rc::Rc; +use wasm_bindgen::{prelude::Closure, JsCast, JsValue}; +use web_sys::ServiceWorkerRegistration; + +use crate::use_window; + +/// Reactive [ServiceWorker API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API). +/// +/// Please check the [working example](https://github.com/Synphonyte/leptos-use/tree/main/examples/use_service_worker). +/// +/// ## Usage +/// +/// ``` +/// # use leptos::*; +/// # use leptos_use::{use_service_worker_with_options, UseServiceWorkerOptions, UseServiceWorkerReturn}; +/// # +/// # #[component] +/// # fn Demo() -> impl IntoView { +/// let UseServiceWorkerReturn { +/// registration, +/// installing, +/// waiting, +/// active, +/// skip_waiting, +/// check_for_update, +/// } = use_service_worker_with_options(UseServiceWorkerOptions::default() +/// .script_url("service-worker.js") +/// .skip_waiting_message("skipWaiting"), +/// ); +/// +/// # view! { } +/// # } +/// ``` +/// +/// ## Server-Side Rendering +/// +/// This function does **not** support SSR. Call it inside a `create_effect`. +pub fn use_service_worker() -> UseServiceWorkerReturn { + use_service_worker_with_options(UseServiceWorkerOptions::default()) +} + +/// Version of [`use_service_worker`] that takes a `UseServiceWorkerOptions`. See [`use_service_worker`] for how to use. +pub fn use_service_worker_with_options( + options: UseServiceWorkerOptions, +) -> UseServiceWorkerReturn { + // Trigger the user-defined action (page-reload by default) + // whenever a new ServiceWorker is installed. + if let Some(navigator) = use_window().navigator() { + let on_controller_change = options.on_controller_change.clone(); + let js_closure = Closure::wrap(Box::new(move |_event: JsValue| { + on_controller_change(); + }) as Box) + .into_js_value(); + navigator + .service_worker() + .set_oncontrollerchange(Some(js_closure.as_ref().unchecked_ref())); + } + + // Create async actions. + let create_or_update_registration = create_action_create_or_update_registration(); + let get_registration = create_action_get_registration(); + let update_sw = create_action_update(); + + // Immediately create or update the SW registration. + create_or_update_registration.dispatch(ServiceWorkerScriptUrl(options.script_url.to_string())); + + // And parse the result into individual signals. + let registration: Signal> = + Signal::derive(move || { + let a = get_registration.value().get(); + let b = create_or_update_registration.value().get(); + // We only dispatch create_or_update_registration once. + // Whenever we manually re-fetched the registration, the result of that has precedence! + match a { + Some(res) => res.map_err(ServiceWorkerRegistrationError::Js), + None => match b { + Some(res) => res.map_err(ServiceWorkerRegistrationError::Js), + None => Err(ServiceWorkerRegistrationError::NeverQueried), + }, + } + }); + + let fetch_registration = Closure::wrap(Box::new(move |_event: JsValue| { + get_registration.dispatch(()); + }) as Box) + .into_js_value(); + + // Handle a changing registration state. + // Notify to developer if SW registration or retrieval fails. + create_effect(move |_| { + registration.with(|reg| match reg { + Ok(registration) => { + // We must be informed when an updated SW is available. + registration.set_onupdatefound(Some(fetch_registration.as_ref().unchecked_ref())); + + // Trigger a check to see IF an updated SW is available. + update_sw.dispatch(registration.clone()); + + // If a SW is installing, we must be notified if its state changes! + if let Some(sw) = registration.installing() { + sw.set_onstatechange(Some(fetch_registration.as_ref().unchecked_ref())); + } + } + Err(err) => match err { + ServiceWorkerRegistrationError::Js(err) => { + logging::warn!("ServiceWorker registration failed: {err:?}") + } + ServiceWorkerRegistrationError::NeverQueried => {} + }, + }) + }); + + UseServiceWorkerReturn { + registration, + installing: Signal::derive(move || { + registration.with(|reg| { + reg.as_ref() + .map(|reg| reg.installing().is_some()) + .unwrap_or_default() + }) + }), + waiting: Signal::derive(move || { + registration.with(|reg| { + reg.as_ref() + .map(|reg| reg.waiting().is_some()) + .unwrap_or_default() + }) + }), + active: Signal::derive(move || { + registration.with(|reg| { + reg.as_ref() + .map(|reg| reg.active().is_some()) + .unwrap_or_default() + }) + }), + check_for_update: move || { + registration.with(|reg| { + if let Ok(reg) = reg { + update_sw.dispatch(reg.clone()) + } + }) + }, + skip_waiting: move || { + registration.with_untracked(|reg| if let Ok(reg) = reg { + match reg.waiting() { + Some(sw) => { + logging::debug_warn!("Updating to newly installed SW..."); + if let Err(err) = sw.post_message(&JsValue::from_str(&options.skip_waiting_message)) { + logging::warn!("Could not send message to active SW: Error: {err:?}"); + } + }, + None => { + logging::warn!("You tried to update the SW while no new SW was waiting. This is probably a bug."); + }, + } + }); + }, + } +} + +/// Options for [`use_service_worker_with_options`]. +#[derive(DefaultBuilder)] +pub struct UseServiceWorkerOptions { + /// The name of your service-worker file. Must be deployed alongside your app. + /// The default name is 'service-worker.js'. + #[builder(into)] + script_url: String, + + /// The message sent to a waiting ServiceWorker when you call the `skip_waiting` callback. + /// The callback is part of the return type of [`use_service_worker`]! + /// The default message is 'skipWaiting'. + #[builder(into)] + skip_waiting_message: String, + + /// What should happen when a new service worker was activated? + /// The default implementation reloads the current page. + on_controller_change: Rc, +} + +impl Default for UseServiceWorkerOptions { + fn default() -> Self { + Self { + script_url: "service-worker.js".into(), + skip_waiting_message: "skipWaiting".into(), + on_controller_change: Rc::new(move || { + use std::ops::Deref; + if let Some(window) = use_window().deref() { + if let Err(err) = window.location().reload() { + logging::warn!( + "Detected a ServiceWorkerController change but the page reload failed! Error: {err:?}" + ); + } + } + }), + } + } +} + +/// Return type of [`use_service_worker`]. +pub struct UseServiceWorkerReturn +where + CheckFn: Fn() + Clone, + SkipFn: Fn() + Clone, +{ + /// The current registration state. + pub registration: Signal>, + + /// Whether a SW is currently installing. + pub installing: Signal, + + /// Whether a SW was installed and is now awaiting activation. + pub waiting: Signal, + + /// Whether a SW is active. + pub active: Signal, + + /// Check for a ServiceWorker update. + pub check_for_update: CheckFn, + + /// Call this to activate a new ("waiting") SW if one is available. + /// Calling this while the [`UseServiceWorkerReturn::waiting`] signal resolves to false has no effect. + pub skip_waiting: SkipFn, +} + +struct ServiceWorkerScriptUrl(pub String); + +#[derive(Debug, Clone)] +pub enum ServiceWorkerRegistrationError { + Js(JsValue), + NeverQueried, +} + +/// A leptos action which asynchronously checks for ServiceWorker updates, given an existing ServiceWorkerRegistration. +fn create_action_update( +) -> Action> { + create_action(move |registration: &ServiceWorkerRegistration| { + let registration = registration.clone(); + async move { + match registration.update() { + Ok(promise) => wasm_bindgen_futures::JsFuture::from(promise) + .await + .and_then(|ok| ok.dyn_into::()), + Err(err) => Err(err), + } + } + }) +} + +/// A leptos action which asynchronously creates or updates and than retrieves the ServiceWorkerRegistration. +fn create_action_create_or_update_registration( +) -> Action> { + create_action(move |script_url: &ServiceWorkerScriptUrl| { + let script_url = script_url.0.to_owned(); + async move { + if let Some(navigator) = use_window().navigator() { + let promise = navigator.service_worker().register(script_url.as_str()); + wasm_bindgen_futures::JsFuture::from(promise) + .await + .and_then(|ok| ok.dyn_into::()) + } else { + Err(JsValue::from_str("no navigator")) + } + } + }) +} + +/// A leptos action which asynchronously fetches the current ServiceWorkerRegistration. +fn create_action_get_registration() -> Action<(), Result> { + create_action(move |(): &()| async move { + if let Some(navigator) = use_window().navigator() { + let promise = navigator.service_worker().get_registration(); + wasm_bindgen_futures::JsFuture::from(promise) + .await + .and_then(|ok| ok.dyn_into::()) + } else { + Err(JsValue::from_str("no navigator")) + } + }) +} diff --git a/src/use_sorted.rs b/src/use_sorted.rs new file mode 100644 index 0000000..32ff806 --- /dev/null +++ b/src/use_sorted.rs @@ -0,0 +1,125 @@ +use leptos::*; +use std::cmp::Ordering; +use std::ops::DerefMut; + +/// Reactive sort of iterable +/// +/// ## Demo +/// +/// [Link to Demo](https://github.com/Synphonyte/leptos-use/tree/main/examples/use_sorted) +/// +/// ## Usage +/// +/// ``` +/// # use leptos::*; +/// # use leptos_use::use_sorted; +/// # +/// # #[component] +/// # fn Demo() -> impl IntoView { +/// let source = vec![10, 3, 5, 7, 2, 1, 8, 6, 9, 4]; +/// let sorted = use_sorted(source); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] +/// # +/// # view! { } +/// # } +/// ``` +/// +/// You can also sort by key or with a compare function. +/// +/// ``` +/// # use leptos::*; +/// # use leptos_use::{use_sorted_by, use_sorted_by_key}; +/// # +/// #[derive(Clone, PartialEq)] +/// pub struct Person { +/// pub name: String, +/// pub age: u16, +/// } +/// +/// # #[component] +/// # fn Demo() -> impl IntoView { +/// let source = vec![ +/// Person { +/// name: "John".to_string(), +/// age: 40, +/// }, +/// Person { +/// name: "Jane".to_string(), +/// age: 20, +/// }, +/// Person { +/// name: "Joe".to_string(), +/// age: 30, +/// }, +/// Person { +/// name: "Jenny".to_string(), +/// age: 22, +/// }, +/// ]; +/// +/// // sort by key +/// let sorted = use_sorted_by_key( +/// source.clone(), +/// |person| person.age, +/// ); +/// +/// // sort with compare function +/// let sorted = use_sorted_by( +/// source, +/// |p1, p2| p1.age.cmp(&p2.age), +/// ); +/// # +/// # view! { } +/// # } +/// ``` +/// +/// Please note that these two ways of sorting are equivalent. +pub fn use_sorted(iterable: S) -> Signal +where + S: Into>, + T: Ord, + I: DerefMut + Clone + PartialEq, +{ + let iterable = iterable.into(); + + create_memo(move |_| { + let mut iterable = iterable.get(); + iterable.sort(); + iterable + }) + .into() +} + +/// Version of [`use_sorted`] with a compare function. +pub fn use_sorted_by(iterable: S, cmp_fn: F) -> Signal +where + S: Into>, + I: DerefMut + Clone + PartialEq, + F: FnMut(&T, &T) -> Ordering + Clone + 'static, +{ + let iterable = iterable.into(); + + create_memo(move |_| { + let mut iterable = iterable.get(); + iterable.sort_by(cmp_fn.clone()); + iterable + }) + .into() +} + +/// Version of [`use_sorted`] by key. +pub fn use_sorted_by_key(iterable: S, key_fn: F) -> Signal +where + S: Into>, + I: DerefMut + Clone + PartialEq, + K: Ord, + F: FnMut(&T) -> K + Clone + 'static, +{ + let iterable = iterable.into(); + + create_memo(move |_| { + let mut iterable = iterable.get(); + iterable.sort_by_key(key_fn.clone()); + iterable + }) + .into() +} diff --git a/src/use_timestamp.rs b/src/use_timestamp.rs index 1718e4f..27024b0 100644 --- a/src/use_timestamp.rs +++ b/src/use_timestamp.rs @@ -1,3 +1,4 @@ +use crate::core::now; use crate::utils::Pausable; use crate::{ use_interval_fn_with_options, use_raf_fn_with_options, UseIntervalFnOptions, UseRafFnOptions, @@ -47,7 +48,8 @@ use std::rc::Rc; /// /// ## Server-Side Rendering /// -/// On the server this function will simply be ignored. +/// On the server this function will return a signal with the milliseconds since the Unix epoch. +/// But the signal will never update (as there's no `request_animation_frame` on the server). pub fn use_timestamp() -> Signal { use_timestamp_with_controls().timestamp } @@ -71,10 +73,10 @@ pub fn use_timestamp_with_controls_and_options(options: UseTimestampOptions) -> callback, } = options; - let (ts, set_ts) = create_signal(js_sys::Date::now() + offset); + let (ts, set_ts) = create_signal(now() + offset); let update = move || { - set_ts.set(js_sys::Date::now() + offset); + set_ts.set(now() + offset); }; let cb = { @@ -82,7 +84,7 @@ pub fn use_timestamp_with_controls_and_options(options: UseTimestampOptions) -> move || { update(); - callback(ts.get()); + callback(ts.get_untracked()); } }; diff --git a/src/use_web_notification.rs b/src/use_web_notification.rs new file mode 100644 index 0000000..44b52a6 --- /dev/null +++ b/src/use_web_notification.rs @@ -0,0 +1,455 @@ +use crate::{use_supported, use_window}; +use cfg_if::cfg_if; +use default_struct_builder::DefaultBuilder; +use leptos::*; +use std::rc::Rc; + +/// Reactive [Notification API](https://developer.mozilla.org/en-US/docs/Web/API/Notification). +/// +/// The Web Notification interface of the Notifications API is used to configure and display desktop notifications to the user. +/// +/// ## Demo +/// +/// [Link to Demo](https://github.com/Synphonyte/leptos-use/tree/main/examples/use_web_notification) +/// +/// ## Usage +/// +/// ``` +/// # use leptos::*; +/// # use leptos_use::{use_web_notification_with_options, UseWebNotificationOptions, ShowOptions, UseWebNotificationReturn, NotificationDirection}; +/// # +/// # #[component] +/// # fn Demo() -> impl IntoView { +/// let UseWebNotificationReturn { +/// show, +/// close, +/// .. +/// } = use_web_notification_with_options( +/// UseWebNotificationOptions::default() +/// .direction(NotificationDirection::Auto) +/// .language("en") +/// .tag("test"), +/// ); +/// +/// show(ShowOptions::default().title("Hello World from leptos-use")); +/// # +/// # view! { } +/// # } +/// ``` +/// +/// ## Server-Side Rendering +/// +/// This function is basically ignored on the server. You can safely call `show` but it will do nothing. +pub fn use_web_notification( +) -> UseWebNotificationReturn { + use_web_notification_with_options(UseWebNotificationOptions::default()) +} + +/// Version of [`use_web_notification`] which takes an [`UseWebNotificationOptions`]. +pub fn use_web_notification_with_options( + options: UseWebNotificationOptions, +) -> UseWebNotificationReturn { + let is_supported = use_supported(browser_supports_notifications); + + let (notification, set_notification) = create_signal(None::); + + let (permission, set_permission) = create_signal(NotificationPermission::default()); + + cfg_if! { if #[cfg(feature = "ssr")] { + let _ = options; + let _ = set_notification; + let _ = set_permission; + + let show = move |_: ShowOptions| (); + let close = move || (); + } else { + use crate::use_event_listener; + use leptos::ev::visibilitychange; + use wasm_bindgen::closure::Closure; + use wasm_bindgen::JsCast; + + let on_click_closure = Closure::::new({ + let on_click = Rc::clone(&options.on_click); + move |e: web_sys::Event| { + on_click(e); + } + }) + .into_js_value(); + + let on_close_closure = Closure::::new({ + let on_close = Rc::clone(&options.on_close); + move |e: web_sys::Event| { + on_close(e); + } + }) + .into_js_value(); + + let on_error_closure = Closure::::new({ + let on_error = Rc::clone(&options.on_error); + move |e: web_sys::Event| { + on_error(e); + } + }) + .into_js_value(); + + let on_show_closure = Closure::::new({ + let on_show = Rc::clone(&options.on_show); + move |e: web_sys::Event| { + on_show(e); + } + }) + .into_js_value(); + + let show = { + let options = options.clone(); + let on_click_closure = on_click_closure.clone(); + let on_close_closure = on_close_closure.clone(); + let on_error_closure = on_error_closure.clone(); + let on_show_closure = on_show_closure.clone(); + + move |options_override: ShowOptions| { + if !is_supported.get_untracked() { + return; + } + + let options = options.clone(); + let on_click_closure = on_click_closure.clone(); + let on_close_closure = on_close_closure.clone(); + let on_error_closure = on_error_closure.clone(); + let on_show_closure = on_show_closure.clone(); + + spawn_local(async move { + set_permission.set(request_web_notification_permission().await); + + let mut notification_options = web_sys::NotificationOptions::from(&options); + options_override.override_notification_options(&mut notification_options); + + let notification_value = web_sys::Notification::new_with_options( + &options_override.title.unwrap_or(options.title), + ¬ification_options, + ) + .expect("Notification should be created"); + + notification_value.set_onclick(Some(on_click_closure.unchecked_ref())); + notification_value.set_onclose(Some(on_close_closure.unchecked_ref())); + notification_value.set_onerror(Some(on_error_closure.unchecked_ref())); + notification_value.set_onshow(Some(on_show_closure.unchecked_ref())); + + set_notification.set(Some(notification_value)); + }); + } + }; + + let close = { + move || { + notification.with_untracked(|notification| { + if let Some(notification) = notification { + notification.close(); + } + }); + set_notification.set(None); + } + }; + + spawn_local(async move { + set_permission.set(request_web_notification_permission().await); + }); + + on_cleanup(close); + + // Use close() to remove a notification that is no longer relevant to to + // the user (e.g.the user already read the notification on the webpage). + // Most modern browsers dismiss notifications automatically after a few + // moments(around four seconds). + if is_supported.get_untracked() { + let _ = use_event_listener(document(), visibilitychange, move |e: web_sys::Event| { + e.prevent_default(); + if document().visibility_state() == web_sys::VisibilityState::Visible { + // The tab has become visible so clear the now-stale Notification: + close() + } + }); + } + }} + + UseWebNotificationReturn { + is_supported, + notification: notification.into(), + show, + close, + permission: permission.into(), + } +} + +#[derive(Default, Clone, Copy, Eq, PartialEq, Debug)] +pub enum NotificationDirection { + #[default] + Auto, + LeftToRight, + RightToLeft, +} + +impl From for web_sys::NotificationDirection { + fn from(direction: NotificationDirection) -> Self { + match direction { + NotificationDirection::Auto => Self::Auto, + NotificationDirection::LeftToRight => Self::Ltr, + NotificationDirection::RightToLeft => Self::Rtl, + } + } +} + +/// Options for [`use_web_notification_with_options`]. +/// See [MDN Docs](https://developer.mozilla.org/en-US/docs/Web/API/notification) for more info. +/// +/// The following implementations are missing: +/// - `renotify` +/// - `vibrate` +/// - `silent` +/// - `image` +#[derive(DefaultBuilder, Clone)] +#[cfg_attr(feature = "ssr", allow(dead_code))] +pub struct UseWebNotificationOptions { + /// The title property of the Notification interface indicates + /// the title of the notification + #[builder(into)] + title: String, + + /// The body string of the notification as specified in the constructor's + /// options parameter. + #[builder(into)] + body: Option, + + /// The text direction of the notification as specified in the constructor's + /// options parameter. Can be `LeftToRight`, `RightToLeft` or `Auto` (default). + /// See [`web_sys::NotificationDirection`] for more info. + direction: NotificationDirection, + + /// The language code of the notification as specified in the constructor's + /// options parameter. + #[builder(into)] + language: Option, + + /// The ID of the notification(if any) as specified in the constructor's options + /// parameter. + #[builder(into)] + tag: Option, + + /// The URL of the image used as an icon of the notification as specified + /// in the constructor's options parameter. + #[builder(into)] + icon: Option, + + /// A boolean value indicating that a notification should remain active until the + /// user clicks or dismisses it, rather than closing automatically. + require_interaction: bool, + + // /// A boolean value specifying whether the user should be notified after a new notification replaces an old one. + // /// The default is `false`, which means they won't be notified. If `true`, then `tag` also must be set. + // #[builder(into)] + // renotify: bool, + /// Called when the user clicks on displayed `Notification`. + on_click: Rc, + + /// Called when the user closes a `Notification`. + on_close: Rc, + + /// Called when something goes wrong with a `Notification` + /// (in many cases an error preventing the notification from being displayed.) + on_error: Rc, + + /// Called when a `Notification` is displayed + on_show: Rc, +} + +impl Default for UseWebNotificationOptions { + fn default() -> Self { + Self { + title: "".to_string(), + body: None, + direction: NotificationDirection::default(), + language: None, + tag: None, + icon: None, + require_interaction: false, + // renotify: false, + on_click: Rc::new(|_| {}), + on_close: Rc::new(|_| {}), + on_error: Rc::new(|_| {}), + on_show: Rc::new(|_| {}), + } + } +} + +impl From<&UseWebNotificationOptions> for web_sys::NotificationOptions { + fn from(options: &UseWebNotificationOptions) -> Self { + let mut web_sys_options = Self::new(); + + web_sys_options + .dir(options.direction.into()) + .require_interaction(options.require_interaction); + // .renotify(options.renotify); + + if let Some(body) = &options.body { + web_sys_options.body(body); + } + + if let Some(icon) = &options.icon { + web_sys_options.icon(icon); + } + + if let Some(language) = &options.language { + web_sys_options.lang(language); + } + + if let Some(tag) = &options.tag { + web_sys_options.tag(tag); + } + + web_sys_options + } +} + +/// Options for [`UseWebNotificationReturn::show`]. +/// This can be used to override options passed to [`use_web_notification`]. +/// See [MDN Docs](https://developer.mozilla.org/en-US/docs/Web/API/notification) for more info. +/// +/// The following implementations are missing: +/// - `vibrate` +/// - `silent` +/// - `image` +#[derive(DefaultBuilder, Default)] +#[cfg_attr(feature = "ssr", allow(dead_code))] +pub struct ShowOptions { + /// The title property of the Notification interface indicates + /// the title of the notification + #[builder(into)] + title: Option, + + /// The body string of the notification as specified in the constructor's + /// options parameter. + #[builder(into)] + body: Option, + + /// The text direction of the notification as specified in the constructor's + /// options parameter. Can be `LeftToRight`, `RightToLeft` or `Auto` (default). + /// See [`web_sys::NotificationDirection`] for more info. + #[builder(into)] + direction: Option, + + /// The language code of the notification as specified in the constructor's + /// options parameter. + #[builder(into)] + language: Option, + + /// The ID of the notification(if any) as specified in the constructor's options + /// parameter. + #[builder(into)] + tag: Option, + + /// The URL of the image used as an icon of the notification as specified + /// in the constructor's options parameter. + #[builder(into)] + icon: Option, + + /// A boolean value indicating that a notification should remain active until the + /// user clicks or dismisses it, rather than closing automatically. + #[builder(into)] + require_interaction: Option, + // /// A boolean value specifying whether the user should be notified after a new notification replaces an old one. + // /// The default is `false`, which means they won't be notified. If `true`, then `tag` also must be set. + // #[builder(into)] + // renotify: Option, +} + +#[cfg(not(feature = "ssr"))] +impl ShowOptions { + fn override_notification_options(&self, options: &mut web_sys::NotificationOptions) { + if let Some(direction) = self.direction { + options.dir(direction.into()); + } + + if let Some(require_interaction) = self.require_interaction { + options.require_interaction(require_interaction); + } + + if let Some(body) = &self.body { + options.body(body); + } + + if let Some(icon) = &self.icon { + options.icon(icon); + } + + if let Some(language) = &self.language { + options.lang(language); + } + + if let Some(tag) = &self.tag { + options.tag(tag); + } + + // if let Some(renotify) = &self.renotify { + // options.renotify(renotify); + // } + } +} + +/// Helper function to determine if browser supports notifications +fn browser_supports_notifications() -> bool { + if let Some(window) = use_window().as_ref() { + if window.has_own_property(&wasm_bindgen::JsValue::from_str("Notification")) { + return true; + } + } + + false +} + +#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)] +/// The permission to send notifications +pub enum NotificationPermission { + /// Notification has not been requested. In effect this is the same as `Denied`. + #[default] + Default, + /// You are allowed to send notifications + Granted, + /// You are *not* allowed to send notifications + Denied, +} + +impl From for NotificationPermission { + fn from(permission: web_sys::NotificationPermission) -> Self { + match permission { + web_sys::NotificationPermission::Default => Self::Default, + web_sys::NotificationPermission::Granted => Self::Granted, + web_sys::NotificationPermission::Denied => Self::Denied, + web_sys::NotificationPermission::__Nonexhaustive => Self::Default, + } + } +} + +/// Use `window.Notification.requestPosition()`. Returns a future that should be awaited +/// at least once before using [`use_web_notification`] to make sure +/// you have the permission to send notifications. +#[cfg(not(feature = "ssr"))] +async fn request_web_notification_permission() -> NotificationPermission { + if let Ok(notification_permission) = web_sys::Notification::request_permission() { + let _ = wasm_bindgen_futures::JsFuture::from(notification_permission).await; + } + + web_sys::Notification::permission().into() +} + +/// Return type for [`use_web_notification`]. +pub struct UseWebNotificationReturn +where + ShowFn: Fn(ShowOptions) + Clone, + CloseFn: Fn() + Clone, +{ + pub is_supported: Signal, + pub notification: Signal>, + pub show: ShowFn, + pub close: CloseFn, + pub permission: Signal, +} diff --git a/src/use_websocket.rs b/src/use_websocket.rs index c50d308..9f40d4f 100644 --- a/src/use_websocket.rs +++ b/src/use_websocket.rs @@ -212,7 +212,6 @@ pub fn use_websocket_with_options( impl Fn(Vec) + Clone, > { let url = normalize_url(url); - logging::log!("{}", url); let UseWebSocketOptions { on_open, on_message, @@ -486,7 +485,7 @@ pub struct UseWebSocketOptions { /// If `false` you have to manually call the `open` function. /// Defaults to `true`. immediate: bool, - /// Sub protocols + /// Sub protocols. See [MDN Docs](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket#protocols). protocols: Option>, } diff --git a/src/use_window.rs b/src/use_window.rs index b9dae5a..b0185f8 100644 --- a/src/use_window.rs +++ b/src/use_window.rs @@ -1,3 +1,4 @@ +use crate::core::impl_ssr_safe_method; use crate::{use_document, UseDocument}; use cfg_if::cfg_if; use std::ops::Deref; @@ -48,14 +49,20 @@ impl Deref for UseWindow { } impl UseWindow { - /// Returns the `Some(Navigator)` in the Browser. `None` otherwise. - pub fn navigator(&self) -> Option { - self.0.as_ref().map(|w| w.navigator()) - } + impl_ssr_safe_method!( + /// Returns `Some(Navigator)` in the Browser. `None` otherwise. + navigator(&self) -> Option + ); /// Returns the same as [`use_document`]. #[inline(always)] pub fn document(&self) -> UseDocument { use_document() } + + impl_ssr_safe_method!( + /// Returns the same as `window().match_media()` in the Browser. `Ok(None)` otherwise. + match_media(&self, query: &str) -> Result, wasm_bindgen::JsValue>; + .unwrap_or(Ok(None)) + ); } diff --git a/src/utils/filters/throttle.rs b/src/utils/filters/throttle.rs index caaa15e..ceda428 100644 --- a/src/utils/filters/throttle.rs +++ b/src/utils/filters/throttle.rs @@ -1,8 +1,8 @@ #![cfg_attr(feature = "ssr", allow(unused_variables, unused_imports))] +use crate::core::now; use cfg_if::cfg_if; use default_struct_builder::DefaultBuilder; -use js_sys::Date; use leptos::leptos_dom::helpers::TimeoutHandle; use leptos::{set_timeout_with_handle, MaybeSignal, SignalGetUntracked}; use std::cell::{Cell, RefCell}; @@ -51,7 +51,7 @@ where move |mut _invoke: Rc R>| { let duration = ms.get_untracked(); - let elapsed = Date::now() - last_exec.get(); + let elapsed = now() - last_exec.get(); let last_return_val = Rc::clone(&last_return_value); let invoke = move || { @@ -65,13 +65,13 @@ where clear(); if duration <= 0.0 { - last_exec.set(Date::now()); + last_exec.set(now()); invoke(); return Rc::clone(&last_return_value); } if elapsed > duration && (options.leading || !is_leading.get()) { - last_exec.set(Date::now()); + last_exec.set(now()); invoke(); } else if options.trailing { cfg_if! { if #[cfg(not(feature = "ssr"))] { @@ -80,7 +80,7 @@ where timer.set( set_timeout_with_handle( move || { - last_exec.set(Date::now()); + last_exec.set(now()); is_leading.set(true); invoke(); clear(); diff --git a/src/utils/is.rs b/src/utils/is.rs index 2fb045c..3655883 100644 --- a/src/utils/is.rs +++ b/src/utils/is.rs @@ -1,8 +1,10 @@ +use crate::use_window; use lazy_static::lazy_static; -use leptos::*; lazy_static! { - pub static ref IS_IOS: bool = if let Ok(user_agent) = window().navigator().user_agent() { + pub static ref IS_IOS: bool = if let Some(Ok(user_agent)) = + use_window().navigator().map(|n| n.user_agent()) + { user_agent.contains("iPhone") || user_agent.contains("iPad") || user_agent.contains("iPod") } else { false