diff --git a/.idea/leptos-use.iml b/.idea/leptos-use.iml index 011b740..3693411 100644 --- a/.idea/leptos-use.iml +++ b/.idea/leptos-use.iml @@ -20,6 +20,7 @@ + @@ -36,6 +37,7 @@ + diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index 1df21ae..e10b133 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -41,4 +41,5 @@ - [use_ceil](math/use_ceil.md) - [use_floor](math/use_floor.md) - [use_max](math/use_max.md) -- [use_min](math/use_min.md) \ No newline at end of file +- [use_min](math/use_min.md) +- [use_round](math/use_round.md) \ No newline at end of file diff --git a/docs/book/src/math/use_round.md b/docs/book/src/math/use_round.md new file mode 100644 index 0000000..638dcfe --- /dev/null +++ b/docs/book/src/math/use_round.md @@ -0,0 +1,3 @@ +# use_round + + diff --git a/docs/book/src/storage/use_storage.md b/docs/book/src/storage/use_storage.md index c848ea6..f937ee4 100644 --- a/docs/book/src/storage/use_storage.md +++ b/docs/book/src/storage/use_storage.md @@ -1 +1,3 @@ # use_storage + + diff --git a/examples/use_round/Cargo.toml b/examples/use_round/Cargo.toml new file mode 100644 index 0000000..bdabb3d --- /dev/null +++ b/examples/use_round/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "use_round" +version = "0.1.0" +edition = "2021" + +[dependencies] +leptos = "0.3" +console_error_panic_hook = "0.1" +console_log = "1" +log = "0.4" +leptos-use = { path = "../..", features = ["docs", "math"] } +web-sys = "0.3" + +[dev-dependencies] +wasm-bindgen = "0.2" +wasm-bindgen-test = "0.3.0" diff --git a/examples/use_round/README.md b/examples/use_round/README.md new file mode 100644 index 0000000..fd5e990 --- /dev/null +++ b/examples/use_round/README.md @@ -0,0 +1,23 @@ +A simple example for `use_round`. + +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_round/Trunk.toml b/examples/use_round/Trunk.toml new file mode 100644 index 0000000..3e4be08 --- /dev/null +++ b/examples/use_round/Trunk.toml @@ -0,0 +1,2 @@ +[build] +public_url = "/demo/" \ No newline at end of file diff --git a/examples/use_round/index.html b/examples/use_round/index.html new file mode 100644 index 0000000..ae249a6 --- /dev/null +++ b/examples/use_round/index.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/examples/use_round/input.css b/examples/use_round/input.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/examples/use_round/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/examples/use_round/rust-toolchain.toml b/examples/use_round/rust-toolchain.toml new file mode 100644 index 0000000..271800c --- /dev/null +++ b/examples/use_round/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" \ No newline at end of file diff --git a/examples/use_round/src/main.rs b/examples/use_round/src/main.rs new file mode 100644 index 0000000..645fbc4 --- /dev/null +++ b/examples/use_round/src/main.rs @@ -0,0 +1,33 @@ +use leptos::*; +use leptos_use::docs::demo_or_body; +use leptos_use::math::use_round; + +#[component] +fn Demo(cx: Scope) -> impl IntoView { + let (value, set_value) = create_signal(cx, 5.95); + + let result: Signal = use_round(cx, value); + + view! { cx, + +

"Value: " {value}

+

"Rounded: " {result}

+ } +} + +fn main() { + _ = console_log::init_with_level(log::Level::Debug); + console_error_panic_hook::set_once(); + + mount_to(demo_or_body(), |cx| { + view! { cx, } + }) +} diff --git a/examples/use_round/style/output.css b/examples/use_round/style/output.css new file mode 100644 index 0000000..ab5191f --- /dev/null +++ b/examples/use_round/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_round/tailwind.config.js b/examples/use_round/tailwind.config.js new file mode 100644 index 0000000..bc09f5e --- /dev/null +++ b/examples/use_round/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 new file mode 100644 index 0000000..6feffb9 --- /dev/null +++ b/examples/use_storage/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "use_storage" +version = "0.1.0" +edition = "2021" + +[dependencies] +leptos = "0.3" +console_error_panic_hook = "0.1" +console_log = "1" +log = "0.4" +leptos-use = { path = "../..", features = ["docs", "storage"] } +web-sys = "0.3" +serde = "1.0.163" + +[dev-dependencies] +wasm-bindgen = "0.2" +wasm-bindgen-test = "0.3.0" diff --git a/examples/use_storage/README.md b/examples/use_storage/README.md new file mode 100644 index 0000000..6a7b2dd --- /dev/null +++ b/examples/use_storage/README.md @@ -0,0 +1,23 @@ +A simple example for `use_storage`. + +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_storage/Trunk.toml b/examples/use_storage/Trunk.toml new file mode 100644 index 0000000..3e4be08 --- /dev/null +++ b/examples/use_storage/Trunk.toml @@ -0,0 +1,2 @@ +[build] +public_url = "/demo/" \ No newline at end of file diff --git a/examples/use_storage/index.html b/examples/use_storage/index.html new file mode 100644 index 0000000..ae249a6 --- /dev/null +++ b/examples/use_storage/index.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/examples/use_storage/input.css b/examples/use_storage/input.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/examples/use_storage/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/examples/use_storage/rust-toolchain.toml b/examples/use_storage/rust-toolchain.toml new file mode 100644 index 0000000..271800c --- /dev/null +++ b/examples/use_storage/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" \ No newline at end of file diff --git a/examples/use_storage/src/main.rs b/examples/use_storage/src/main.rs new file mode 100644 index 0000000..f58a270 --- /dev/null +++ b/examples/use_storage/src/main.rs @@ -0,0 +1,74 @@ +use leptos::*; +use leptos_use::docs::{demo_or_body, Note}; +use leptos_use::storage::use_storage; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct BananaState { + pub name: String, + pub color: String, + pub size: String, + pub count: u32, +} + +#[component] +fn Demo(cx: Scope) -> 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(cx, "banana-state", the_default.clone()); + + let (state2, ..) = use_storage(cx, "banana-state", the_default.clone()); + + view! { cx, + + + + ().unwrap() as u32) + type="number" + min="0" + step="1" + max="1000" + /> + +

"Second ""use_storage"":"

+ +
+            { move || format!("{:#?}", state2()) }
+        
+ + "The values are persistant. When you reload the page the values will be the same." + } +} + +fn main() { + _ = console_log::init_with_level(log::Level::Debug); + console_error_panic_hook::set_once(); + + mount_to(demo_or_body(), |cx| { + view! { cx, } + }) +} diff --git a/examples/use_storage/style/output.css b/examples/use_storage/style/output.css new file mode 100644 index 0000000..ab5191f --- /dev/null +++ b/examples/use_storage/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_storage/tailwind.config.js b/examples/use_storage/tailwind.config.js new file mode 100644 index 0000000..bc09f5e --- /dev/null +++ b/examples/use_storage/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/src/lib.rs b/src/lib.rs index 1091298..92322bb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ mod use_throttle_fn; pub mod utils; mod watch; mod watch_debounced; +mod watch_pausable; mod watch_throttled; pub use use_debounce_fn::*; @@ -35,6 +36,7 @@ pub use use_supported::*; pub use use_throttle_fn::*; pub use watch::*; pub use watch_debounced::*; +pub use watch_pausable::*; pub use watch_throttled::*; extern crate self as leptos_use; diff --git a/src/math/mod.rs b/src/math/mod.rs index 90e0e38..e2385e0 100644 --- a/src/math/mod.rs +++ b/src/math/mod.rs @@ -6,8 +6,10 @@ mod use_ceil; mod use_floor; mod use_max; mod use_min; +mod use_round; pub use use_ceil::*; pub use use_floor::*; pub use use_max::*; pub use use_min::*; +pub use use_round::*; diff --git a/src/math/shared.rs b/src/math/shared.rs index c973151..0bd3ba1 100644 --- a/src/math/shared.rs +++ b/src/math/shared.rs @@ -1,5 +1,3 @@ -use paste::paste; - macro_rules! use_partial_cmp { ($(#[$outer:meta])* $fn_name:ident, diff --git a/src/math/use_round.rs b/src/math/use_round.rs new file mode 100644 index 0000000..5cfada4 --- /dev/null +++ b/src/math/use_round.rs @@ -0,0 +1,30 @@ +use leptos::*; +use leptos_use::math::shared::use_simple_math; +use num::Float; +use paste::paste; + +use_simple_math!( + /// Reactive `round()`. + /// + /// ## Demo + /// + /// [Link to Demo](https://github.com/Synphonyte/leptos-use/tree/main/examples/use_round) + /// + /// ## Usage + /// + /// ``` + /// # use leptos::*; + /// # use leptos_use::math::use_round; + /// # + /// # #[component] + /// # fn Demo(cx: Scope) -> impl IntoView { + /// let (value, set_value) = create_signal(cx, 45.95); + /// let result: Signal = use_round(cx, value); // 46 + /// # + /// # assert_eq!(result.get(), 46.0); + /// # view! { cx, } + /// # } + /// ``` + #[doc(cfg(feature = "math"))] + round +); diff --git a/src/storage/use_storage.rs b/src/storage/use_storage.rs index 6f2fbe9..b0b0e50 100644 --- a/src/storage/use_storage.rs +++ b/src/storage/use_storage.rs @@ -1,495 +1,498 @@ -// use crate::utils::{CloneableFnWithArg, FilterOptions}; -// use crate::{ -// filter_builder_methods, use_event_listener, watch_pausable_with_options, DebounceOptions, -// ThrottleOptions, WatchOptions, WatchPausableReturn, -// }; -// use default_struct_builder::DefaultBuilder; -// use js_sys::Reflect; -// use leptos::*; -// use serde::{Deserialize, Serialize}; -// use serde_json::Error; -// use std::time::Duration; -// use wasm_bindgen::{JsCast, JsValue}; -// -// 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) -// /// -// /// ## Demo -// /// -// /// [Link to Demo](https://github.com/Synphonyte/leptos-use/tree/main/examples/use_storage) -// /// -// /// ## Usage -// /// -// /// It returns a triplet `(read_signal, write_signal, delete_from_storage_func)` of type `(Signal, WriteSignal, Fn())`. -// /// -// /// ``` -// /// # use leptos::*; -// /// # use leptos_use::storage::{StorageType, use_storage, use_storage_with_options, UseStorageOptions}; -// /// # use serde::{Deserialize, Serialize}; -// /// # -// /// #[derive(Serialize, Deserialize, Clone)] -// /// pub struct MyState { -// /// pub hello: String, -// /// pub greeting: String, -// /// } -// /// # -// /// # pub fn Demo(cx: Scope) -> impl IntoView { -// /// // bind struct. Must be serializable. -// /// let (state, set_state, _) = use_storage( -// /// cx, -// /// "my-state", -// /// MyState { -// /// hello: "hi".to_string(), -// /// greeting: "Hello".to_string() -// /// }, -// /// ); // returns Signal -// /// -// /// // bind bool. -// /// let (flag, set_flag, remove_flag) = use_storage(cx, "my-flag", true); // returns Signal -// /// -// /// // bind number -// /// let (count, set_count, _) = use_storage(cx, "my-count", 0); // returns Signal -// /// -// /// // bind string with SessionStorage -// /// let (id, set_id, _) = use_storage_with_options( -// /// cx, -// /// "my-id", -// /// "some_string_id".to_string(), -// /// UseStorageOptions::default().storage_type(StorageType::Session), -// /// ); -// /// # view! { cx, } -// /// # } -// /// ``` -// /// -// /// ## 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(cx, "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( -// /// cx, -// /// "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)] -// /// pub struct MyState { -// /// pub hello: String, -// /// pub greeting: String, -// /// } -// /// # -// /// # pub fn Demo(cx: Scope) -> impl IntoView { -// /// let (state, set_state) = use_storage_with_options( -// /// cx, -// /// "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)) -// /// } -// /// }), -// /// ); -// /// # -// /// # view! { cx, } -// /// # } -// /// ``` -// /// -// /// ## Filter Storage Write -// /// -// /// You can specify `debounce` and `throttle` filter options for writing to storage. -// /// -// /// ## See also -// /// -// /// -// /// * [`use_local_storage`] -// /// * [`use_session_storage`] -// #[doc(cfg(feature = "storage"))] -// pub fn use_storage( -// cx: Scope, -// 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(cx, key, defaults, 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( -// cx: Scope, -// key: &str, -// defaults: D, -// options: UseStorageOptions, -// ) -> (Signal, WriteSignal>, impl Fn() + Clone) -// where -// for<'de> T: Serialize + Deserialize<'de> + Clone + 'static, -// D: Into>, -// T: Clone, -// { -// let defaults = defaults.into(); -// -// let UseStorageOptions { -// storage_type, -// listen_to_storage_changes, -// write_defaults, -// merge_defaults, -// on_error, -// filter, -// } = options; -// -// let (data, set_data) = create_signal(cx, defaults.get_untracked()); -// -// let storage = storage_type.into_storage(); -// -// let remove = match storage { -// Ok(Some(storage)) => { -// let on_err = on_error.clone(); -// -// let store = storage.clone(); -// let k = key.to_string(); -// -// let write = move |v: &Option| { -// if let Some(v) = v { -// match serde_json::to_string(&v) { -// Ok(ref serialized) => match store.get_item(&k) { -// Ok(old_value) => { -// if old_value.as_ref() != Some(serialized) { -// if let Err(e) = store.set_item(&k, &serialized) { -// on_err(UseStorageError::StorageAccessError(e)); -// } else { -// let mut event_init = web_sys::CustomEventInit::new(); -// event_init.detail( -// &StorageEventDetail { -// key: Some(k.clone()), -// old_value, -// new_value: Some(serialized.clone()), -// storage_area: Some(store.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_err.clone()(UseStorageError::StorageAccessError(e)); -// } -// }, -// Err(e) => { -// on_err.clone()(UseStorageError::SerializationError(e)); -// } -// } -// } else if let Err(e) = store.remove_item(&k) { -// on_err(UseStorageError::StorageAccessError(e)); -// } -// }; -// -// let store = storage.clone(); -// let on_err = on_error.clone(); -// let k = key.to_string(); -// let def = defaults.clone(); -// -// let read = move |event_detail: Option| -> Option { -// let raw_init = match serde_json::to_string(&def.get_untracked()) { -// Ok(serialized) => Some(serialized), -// Err(e) => { -// on_err.clone()(UseStorageError::DefaultSerializationError(e)); -// None -// } -// }; -// -// let raw_value = if let Some(event_detail) = event_detail { -// event_detail.new_value -// } else { -// match store.get_item(&k) { -// Ok(raw_value) => match raw_value { -// Some(raw_value) => { -// Some(merge_defaults(&raw_value, &def.get_untracked())) -// } -// None => raw_init.clone(), -// }, -// Err(e) => { -// on_err.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_err.clone()(UseStorageError::SerializationError(e)); -// None -// } -// }, -// None => { -// if let Some(raw_init) = &raw_init { -// if write_defaults { -// if let Err(e) = store.set_item(&k, raw_init) { -// on_err(UseStorageError::StorageAccessError(e)); -// } -// } -// } -// -// Some(def.get_untracked()) -// } -// } -// }; -// -// let WatchPausableReturn { -// pause: pause_watch, -// resume: resume_watch, -// .. -// } = watch_pausable_with_options( -// cx, -// data, -// move |data, _, _| write.clone()(data), -// WatchOptions::default().immediate(true).filter(filter), -// ); -// -// let k = key.to_string(); -// -// let update = move |event_detail: Option| { -// if let Some(event_detail) = &event_detail { -// if event_detail.storage_area != Some(storage) { -// return; -// } -// -// match &event_detail.key { -// None => { -// set_data(Some(defaults.get_untracked())); -// return; -// } -// Some(event_key) => { -// if event_key != &k { -// return; -// } -// } -// }; -// } -// -// pause_watch(); -// -// set_data(read(event_detail.clone())); -// -// if event_detail.is_some() { -// // use timeout to avoid inifinite loop -// let resume = resume_watch.clone(); -// let _ = set_timeout_with_handle(resume, Duration::ZERO); -// } else { -// resume_watch(); -// } -// }; -// -// let upd = update.clone(); -// let update_from_custom_event = -// move |event: web_sys::CustomEvent| upd.clone()(Some(event.into())); -// -// let upd = update.clone(); -// let update_from_storage_event = -// move |event: web_sys::StorageEvent| upd.clone()(Some(event.into())); -// -// if listen_to_storage_changes { -// let _ = use_event_listener(cx, window(), ev::storage, update_from_storage_event); -// let _ = use_event_listener( -// cx, -// window(), -// ev::Custom::new(CUSTOM_STORAGE_EVENT_NAME), -// update_from_custom_event, -// ); -// } -// -// update(None); -// -// move || storage.remove_item(&k) -// } -// Err(e) => { -// on_error(UseStorageError::NoStorage(e)); -// move || {} -// } -// _ => { -// // do nothing -// move || {} -// } -// }; -// -// (data, set_data, remove) -// } -// -// #[derive(Clone)] -// pub struct StorageEventDetail { -// pub key: Option, -// pub old_value: Option, -// pub new_value: Option, -// pub storage_area: Option, -// } -// -// 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(), -// } -// } -// } -// -// 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(), -// } -// } -// } -// -// 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() -// } -// } -// -// 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)` -// storage_type: StorageType, -// /// 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: Box>, -// -// /// Debounce or throttle the writing to storage whenever the value changes. -// filter: FilterOptions, -// } -// -// impl Default for UseStorageOptions { -// fn default() -> Self { -// Self { -// storage_type: Default::default(), -// listen_to_storage_changes: true, -// write_defaults: true, -// merge_defaults: |stored_value, _default_value| stored_value.to_string(), -// on_error: Box::new(|_| ()), -// filter: Default::default(), -// } -// } -// } -// -// impl UseStorageOptions { -// filter_builder_methods!( -// /// the serializing and storing into storage -// filter -// ); -// } -// -// /// Local or session storage or a custom store that is a `web_sys::Storage`. -// #[doc(cfg(feature = "storage"))] -// #[derive(Default)] -// pub enum StorageType { -// #[default] -// Local, -// Session, -// Custom(web_sys::Storage), -// } -// -// impl StorageType { -// pub fn into_storage(self) -> Result, JsValue> { -// match self { -// StorageType::Local => window().local_storage(), -// StorageType::Session => window().session_storage(), -// StorageType::Custom(storage) => Ok(Some(storage)), -// } -// } -// } +use crate::utils::{CloneableFn, CloneableFnWithArg, FilterOptions}; +use crate::{ + filter_builder_methods, use_event_listener, watch_pausable_with_options, DebounceOptions, + ThrottleOptions, WatchOptions, WatchPausableReturn, +}; +use default_struct_builder::DefaultBuilder; +use js_sys::Reflect; +use leptos::*; +use serde::{Deserialize, Serialize}; +use serde_json::Error; +use std::time::Duration; +use wasm_bindgen::{JsCast, JsValue}; + +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) +/// +/// ## Demo +/// +/// [Link to Demo](https://github.com/Synphonyte/leptos-use/tree/main/examples/use_storage) +/// +/// ## Usage +/// +/// It returns a triplet `(read_signal, write_signal, delete_from_storage_func)` of type `(ReadSignal, WriteSignal, Fn())`. +/// +/// ``` +/// # use leptos::*; +/// # use leptos_use::storage::{StorageType, use_storage, use_storage_with_options, UseStorageOptions}; +/// # use serde::{Deserialize, Serialize}; +/// # +/// #[derive(Serialize, Deserialize, Clone)] +/// pub struct MyState { +/// pub hello: String, +/// pub greeting: String, +/// } +/// +/// # pub fn Demo(cx: Scope) -> impl IntoView { +/// // bind struct. Must be serializable. +/// let (state, set_state, _) = use_storage( +/// cx, +/// "my-state", +/// MyState { +/// hello: "hi".to_string(), +/// greeting: "Hello".to_string() +/// }, +/// ); // returns Signal +/// +/// // bind bool. +/// let (flag, set_flag, remove_flag) = use_storage(cx, "my-flag", true); // returns Signal +/// +/// // bind number +/// let (count, set_count, _) = use_storage(cx, "my-count", 0); // returns Signal +/// +/// // bind string with SessionStorage +/// let (id, set_id, _) = use_storage_with_options( +/// cx, +/// "my-id", +/// "some_string_id".to_string(), +/// UseStorageOptions::default().storage_type(StorageType::Session), +/// ); +/// # view! { cx, } +/// # } +/// ``` +/// +/// ## 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(cx, "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( +/// cx, +/// "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)] +/// pub struct MyState { +/// pub hello: String, +/// pub greeting: String, +/// } +/// # +/// # pub fn Demo(cx: Scope) -> impl IntoView { +/// let (state, set_state, _) = use_storage_with_options( +/// cx, +/// "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)) +/// } +/// }), +/// ); +/// # +/// # view! { cx, } +/// # } +/// ``` +/// +/// ## Filter Storage Write +/// +/// You can specify `debounce` and `throttle` filter options for writing to storage. +/// +/// ## See also +/// +/// +/// * [`use_local_storage`] +/// * [`use_session_storage`] +#[doc(cfg(feature = "storage"))] +pub fn use_storage( + cx: Scope, + key: &str, + defaults: D, +) -> (ReadSignal, WriteSignal, impl Fn() + Clone) +where + for<'de> T: Serialize + Deserialize<'de> + Clone + 'static, + D: Into>, + T: Clone, +{ + use_storage_with_options(cx, key, defaults, 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( + cx: Scope, + key: &str, + defaults: D, + options: UseStorageOptions, +) -> (ReadSignal, WriteSignal, impl Fn() + Clone) +where + for<'de> T: Serialize + Deserialize<'de> + Clone + 'static, + D: Into>, + T: Clone, +{ + let defaults = defaults.into(); + + let UseStorageOptions { + storage_type, + listen_to_storage_changes, + write_defaults, + merge_defaults, + on_error, + filter, + } = options; + + let (data, set_data) = create_signal(cx, defaults.get_untracked()); + + let storage = storage_type.into_storage(); + + let remove: Box = match storage { + Ok(Some(storage)) => { + let on_err = on_error.clone(); + + let store = storage.clone(); + let k = key.to_string(); + + let write = move |v: &T| { + match serde_json::to_string(&v) { + Ok(ref serialized) => match store.get_item(&k) { + Ok(old_value) => { + if old_value.as_ref() != Some(serialized) { + if let Err(e) = store.set_item(&k, &serialized) { + on_err(UseStorageError::StorageAccessError(e)); + } else { + let mut event_init = web_sys::CustomEventInit::new(); + event_init.detail( + &StorageEventDetail { + key: Some(k.clone()), + old_value, + new_value: Some(serialized.clone()), + storage_area: Some(store.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_err.clone()(UseStorageError::StorageAccessError(e)); + } + }, + Err(e) => { + on_err.clone()(UseStorageError::SerializationError(e)); + } + } + }; + + let store = storage.clone(); + let on_err = on_error.clone(); + let k = key.to_string(); + let def = defaults.clone(); + + let read = move |event_detail: Option| -> Option { + let raw_init = match serde_json::to_string(&def.get_untracked()) { + Ok(serialized) => Some(serialized), + Err(e) => { + on_err.clone()(UseStorageError::DefaultSerializationError(e)); + None + } + }; + + let raw_value = if let Some(event_detail) = event_detail { + event_detail.new_value + } else { + match store.get_item(&k) { + Ok(raw_value) => match raw_value { + Some(raw_value) => { + Some(merge_defaults(&raw_value, &def.get_untracked())) + } + None => raw_init.clone(), + }, + Err(e) => { + on_err.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_err.clone()(UseStorageError::SerializationError(e)); + None + } + }, + None => { + if let Some(raw_init) = &raw_init { + if write_defaults { + if let Err(e) = store.set_item(&k, raw_init) { + on_err(UseStorageError::StorageAccessError(e)); + } + } + } + + Some(def.get_untracked()) + } + } + }; + + let WatchPausableReturn { + pause: pause_watch, + resume: resume_watch, + .. + } = watch_pausable_with_options( + cx, + data, + move |data, _, _| write.clone()(data), + WatchOptions::default().filter(filter), + ); + + let k = key.to_string(); + let store = storage.clone(); + + let update = move |event_detail: Option| { + if let Some(event_detail) = &event_detail { + if event_detail.storage_area != Some(store) { + return; + } + + match &event_detail.key { + None => { + set_data(defaults.get_untracked()); + return; + } + Some(event_key) => { + if event_key != &k { + return; + } + } + }; + } + + pause_watch(); + + if let Some(value) = read(event_detail.clone()) { + set_data(value); + } + + if event_detail.is_some() { + // use timeout to avoid inifinite loop + let resume = resume_watch.clone(); + let _ = set_timeout_with_handle(resume, Duration::ZERO); + } else { + resume_watch(); + } + }; + + let upd = update.clone(); + let update_from_custom_event = + move |event: web_sys::CustomEvent| upd.clone()(Some(event.into())); + + let upd = update.clone(); + let update_from_storage_event = + move |event: web_sys::StorageEvent| upd.clone()(Some(event.into())); + + if listen_to_storage_changes { + let _ = use_event_listener(cx, window(), ev::storage, update_from_storage_event); + let _ = use_event_listener( + cx, + window(), + ev::Custom::new(CUSTOM_STORAGE_EVENT_NAME), + update_from_custom_event, + ); + } + + update(None); + + let k = key.to_string(); + + Box::new(move || { + let _ = storage.remove_item(&k); + }) + } + Err(e) => { + on_error(UseStorageError::NoStorage(e)); + Box::new(move || {}) + } + _ => { + // do nothing + Box::new(move || {}) + } + }; + + (data, set_data, move || remove.clone()()) +} + +#[derive(Clone)] +pub struct StorageEventDetail { + pub key: Option, + pub old_value: Option, + pub new_value: Option, + pub storage_area: Option, +} + +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(), + } + } +} + +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(), + } + } +} + +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() + } +} + +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)` + storage_type: StorageType, + /// 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: Box>, + + /// Debounce or throttle the writing to storage whenever the value changes. + filter: FilterOptions, +} + +impl Default for UseStorageOptions { + fn default() -> Self { + Self { + storage_type: Default::default(), + listen_to_storage_changes: true, + write_defaults: true, + merge_defaults: |stored_value, _default_value| stored_value.to_string(), + on_error: Box::new(|_| ()), + filter: Default::default(), + } + } +} + +impl UseStorageOptions { + filter_builder_methods!( + /// the serializing and storing into storage + filter + ); +} + +/// Local or session storage or a custom store that is a `web_sys::Storage`. +#[doc(cfg(feature = "storage"))] +#[derive(Default)] +pub enum StorageType { + #[default] + Local, + Session, + Custom(web_sys::Storage), +} + +impl StorageType { + pub fn into_storage(self) -> Result, JsValue> { + match self { + StorageType::Local => window().local_storage(), + StorageType::Session => window().session_storage(), + StorageType::Custom(storage) => Ok(Some(storage)), + } + } +}