diff --git a/Cargo.toml b/Cargo.toml index 75d5779..0ba619f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,12 +86,19 @@ web-sys = { version = "0.3", default-features = false, features = [ "DomTokenList", "DragEvent", "Element", + "Event", + "EventTarget", "Headers", "HtmlElement", + "HtmlInputElement", + "InputEvent", "KeyboardEvent", "Location", "MediaQueryList", "MouseEvent", + "MutationObserver", + "MutationObserverInit", + "MutationRecord", "Navigator", "Node", "NodeList", diff --git a/app/src/domain/markdown_ui/components/static_demo_wrapper.rs b/app/src/domain/markdown_ui/components/static_demo_wrapper.rs index 1d9085f..9c64379 100644 --- a/app/src/domain/markdown_ui/components/static_demo_wrapper.rs +++ b/app/src/domain/markdown_ui/components/static_demo_wrapper.rs @@ -29,6 +29,7 @@ fn transform_code_for_display(code: &str) -> String { #[component] pub fn StaticDemoWrapper(demo_type: MarkdownType, children: Children) -> impl IntoView { let current_tab = RwSignal::new(Tab::default()); + let use_embedded_blocks = matches!(demo_type, MarkdownType::StaticDemoInputOtp); // Zero-allocation static lookup let Some(demo_data) = get_static_registry_entry(demo_type) else { @@ -60,6 +61,10 @@ pub fn StaticDemoWrapper(demo_type: MarkdownType, children: Children) -> impl In // Store children at the top level so we can reference it reactively let children_view = children(); + if use_embedded_blocks { + return view! {
{children_view}
}.into_any(); + } + view! {
diff --git a/app_crates/registry/src/demos/demo_input_otp.rs b/app_crates/registry/src/demos/demo_input_otp.rs index 2e38baf..0558454 100644 --- a/app_crates/registry/src/demos/demo_input_otp.rs +++ b/app_crates/registry/src/demos/demo_input_otp.rs @@ -1,10 +1,92 @@ +use icons::{Code, Eye, Terminal}; use leptos::prelude::*; +use wasm_bindgen::JsCast; +use wasm_bindgen::closure::Closure; +use crate::ui::button::{Button, ButtonSize, ButtonVariant}; use crate::ui::input_otp::{InputOTP, InputOTPGroup, InputOTPSlot}; -#[component] -pub fn DemoInputOtp() -> impl IntoView { - view! { +const PAGE_CLASS: &str = "w-full"; +const BLOCK_CLASS: &str = "block w-full space-y-3 mb-10 last:mb-0"; +const TITLE_CLASS: &str = "text-2xl font-semibold tracking-tight"; +const DESCRIPTION_CLASS: &str = "max-w-2xl text-sm leading-6 text-muted-foreground"; +const ROW_CLASS: &str = "flex justify-between items-center"; +const PREVIEW_CLASS: &str = "border rounded-xl w-full"; +const CENTER_CLASS: &str = "flex justify-center items-center w-full min-h-[370px] px-4"; +const CODE_BOX_CLASS: &str = "overflow-x-auto rounded-xl border bg-muted/40 p-4"; +const CODE_TEXT_CLASS: &str = "text-sm leading-6 whitespace-pre-wrap text-foreground"; +const TAB_BASE_CLASS: &str = "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all h-8 px-3 py-2 has-[>svg]:px-2.5 border outline-none"; +const TAB_ACTIVE_CLASS: &str = "bg-secondary text-secondary-foreground shadow-xs"; +const TAB_INACTIVE_CLASS: &str = "bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/5"; +const BASIC_CODE: &str = r#"use crate::ui::input_otp::{InputOTP, InputOTPGroup, InputOTPSlot}; + +view! { + + + + + + + + + + +}"#; + +const FOUR_DIGIT_CODE: &str = r#"use crate::ui::input_otp::{InputOTP, InputOTPGroup, InputOTPSlot}; + +view! { + + + + + + + + +}"#; + +const FILTER_CODE: &str = r#"let rejected = RwSignal::new(String::from("—")); + +view! { + + + + + + + + + + + +

+ "Last rejected: " + {move || rejected.get()} +

+}"#; + +const FOCUS_CODE: &str = r#"use crate::ui::input_otp::{InputOTP, InputOTPGroup, InputOTPSlot}; + +view! { + + + + + + + + + + +}"#; + +const DYNAMIC_CODE: &str = r#"let show_dynamic = RwSignal::new(false); + +view! { + + + @@ -15,5 +97,335 @@ pub fn DemoInputOtp() -> impl IntoView { + +}"#; + +#[derive(Clone, Copy, PartialEq, Eq, Default)] +enum DemoTab { + #[default] + Preview, + Code, +} + +#[component] +pub fn DemoInputOtp() -> impl IntoView { + let show_dynamic = RwSignal::new(false); + + view! { +
+ + + + + +
} } + +#[component] +fn BasicBlock() -> impl IntoView { + let tab = RwSignal::new(DemoTab::Preview); + + view! { +
+

"Basic OTP"

+

+ "Six-slot OTP input controlled entirely in Rust. Type digits and watch the slots fill left to right." +

+ + {move || { + if tab.get() == DemoTab::Preview { + view! { + + + + + + } + .into_any() + } else { + view! { }.into_any() + } + }} +
+ } +} + +#[component] +fn FourDigitBlock() -> impl IntoView { + let tab = RwSignal::new(DemoTab::Preview); + + view! { +
+

"4-Digit Variant"

+

+ "The same Rust controller wired to four-slot markup. Slot count comes from the DOM structure, not hardcoded." +

+ + {move || { + if tab.get() == DemoTab::Preview { + view! { + + + + + + } + .into_any() + } else { + view! { }.into_any() + } + }} +
+ } +} + +#[component] +fn DigitFilterBlock() -> impl IntoView { + let tab = RwSignal::new(DemoTab::Preview); + let rejected = RwSignal::new(String::from("—")); + + view! { +
+

"Digit Filter"

+

+ "Non-digit input is rejected before it reaches the hidden value. Type letters or symbols to see the filter in action." +

+ + {move || { + if tab.get() == DemoTab::Preview { + view! { +
+ + + + + +

+ "Last rejected: " + {move || rejected.get()} +

+
+ } + .into_any() + } else { + view! { }.into_any() + } + }} +
+ } +} + +#[component] +fn FocusBlurBlock() -> impl IntoView { + let tab = RwSignal::new(DemoTab::Preview); + + view! { +
+

"Focus and Blur"

+

+ "The active slot highlight tracks the cursor and clears on blur. Click a slot to focus, click outside to blur." +

+ + {move || { + if tab.get() == DemoTab::Preview { + view! { + + + + + + } + .into_any() + } else { + view! { }.into_any() + } + }} +
+ } +} + +#[component] +fn PostHydrationBlock(show_dynamic: RwSignal) -> impl IntoView { + let tab = RwSignal::new(DemoTab::Preview); + + view! { +
+

"Post-Hydration Init"

+

+ "A new OTP root is injected after page load. The MutationObserver initializes it immediately without a reload." +

+ + {move || { + if tab.get() == DemoTab::Preview { + view! { + +
+ + +
+ +
+
+
+
+ } + .into_any() + } else { + view! { }.into_any() + } + }} +
+ } +} + +#[component] +fn BlockToolbar(tab: RwSignal, cli_label: &'static str) -> impl IntoView { + view! { +
+
+ + +
+ +
+ +
+
+ } +} + +#[component] +fn PreviewShell(children: Children) -> impl IntoView { + view! { +
+ {children()} +
+ } +} + +#[component] +fn CenteredPreview(children: Children) -> impl IntoView { + view! { +
+ {children()} +
+ } +} + +#[component] +fn CodePanel(code: &'static str) -> impl IntoView { + view! { +
+
{code}
+
+ } +} + +#[component] +fn OtpInput(max_length: usize, class: &'static str) -> impl IntoView { + view! { + + + } + /> + + + } +} + +#[component] +fn OtpInputWithRejected(max_length: usize, rejected: RwSignal, class: &'static str) -> impl IntoView { + let root_ref = NodeRef::::new(); + use_rejected_input_sync(root_ref, rejected); + + view! { +
+ +
+ } +} + +fn use_rejected_input_sync(root_ref: NodeRef, rejected: RwSignal) { + Effect::new(move |_| { + let Some(root) = root_ref.get() else { return }; + if root.get_attribute("data-demo-otp-rejection").is_some() { + return; + } + + let Ok(Some(input)) = root.query_selector("input[data-otp-input]") else { + return; + }; + let Ok(input) = input.dyn_into::() else { + return; + }; + let _ = root.set_attribute("data-demo-otp-rejection", "true"); + + let target: web_sys::EventTarget = input.unchecked_into(); + let listener = Closure::wrap(Box::new(move |event: web_sys::Event| { + let Some(input_event) = event.dyn_ref::() else { + return; + }; + + if input_event.input_type() != "insertText" { + return; + } + + let Some(data) = input_event.data() else { return }; + if data.chars().all(|ch| ch.is_ascii_digit()) { + return; + } + + rejected.set(data); + }) as Box); + + let _ = target.add_event_listener_with_callback("beforeinput", listener.as_ref().unchecked_ref()); + listener.forget(); + }); +} diff --git a/app_crates/registry/src/hooks/mod.rs b/app_crates/registry/src/hooks/mod.rs index 0111dcc..f3b1e1d 100644 --- a/app_crates/registry/src/hooks/mod.rs +++ b/app_crates/registry/src/hooks/mod.rs @@ -14,6 +14,7 @@ pub mod use_handle_day_click; pub mod use_history; pub mod use_horizontal_scroll; pub mod use_is_mobile; +pub mod use_input_otp; pub mod use_lock_body_scroll; pub mod use_lock_body_scroll_dialog; pub mod use_lock_body_scroll_popover; diff --git a/app_crates/registry/src/hooks/use_input_otp.rs b/app_crates/registry/src/hooks/use_input_otp.rs new file mode 100644 index 0000000..d27c688 --- /dev/null +++ b/app_crates/registry/src/hooks/use_input_otp.rs @@ -0,0 +1,414 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use wasm_bindgen::JsCast; +use wasm_bindgen::closure::Closure; +use web_sys::{Element, Event, EventTarget, HtmlElement, HtmlInputElement, InputEvent, MutationObserver, MutationObserverInit, MutationRecord, Node}; + +const OTP_ROOT_SELECTOR: &str = "[data-otp-root]"; +const OTP_INPUT_SELECTOR: &str = "input[data-otp-input]"; +const OTP_SLOT_SELECTOR: &str = "[data-otp-slot]"; +const OTP_CHAR_SELECTOR: &str = "[data-otp-char]"; +const OTP_CARET_SELECTOR: &str = "[data-otp-caret]"; +const OTP_KEY_ATTR: &str = "data-otp-key"; + +thread_local! { + static MANAGER: RefCell> = const { RefCell::new(None) }; +} + +pub fn init() { + MANAGER.with(|manager| { + if manager.borrow().is_some() { + return; + } + + let mut otp_manager = OtpManager::new(); + otp_manager.init_all(); + otp_manager.observe_dom(); + *manager.borrow_mut() = Some(otp_manager); + }); +} + +struct OtpManager { + controllers: HashMap, + next_key: u64, + observer: Option, + observer_callback: Option>, +} + +impl OtpManager { + fn new() -> Self { + Self { controllers: HashMap::new(), next_key: 0, observer: None, observer_callback: None } + } + + fn init_all(&mut self) { + let Some(document) = document() else { return }; + let Ok(nodes) = document.query_selector_all(OTP_ROOT_SELECTOR) else { + return; + }; + + for idx in 0..nodes.length() { + let Some(node) = nodes.item(idx) else { continue }; + let Ok(root) = node.dyn_into::() else { continue }; + self.init_container(root); + } + } + + fn init_container(&mut self, root: Element) { + let key = self.ensure_key(&root); + if self.controllers.contains_key(&key) { + return; + } + + let Some(controller) = OtpController::new(root) else { + return; + }; + + self.controllers.insert(key, controller); + } + + fn remove_container(&mut self, root: &Element) { + let Some(key) = root.get_attribute(OTP_KEY_ATTR) else { + return; + }; + + self.controllers.remove(&key); + } + + fn handle_mutations(&mut self, records: js_sys::Array) { + for record in records.iter() { + let Ok(record) = record.dyn_into::() else { continue }; + + self.handle_node_list(record.removed_nodes(), false); + self.handle_node_list(record.added_nodes(), true); + } + } + + fn handle_node_list(&mut self, nodes: web_sys::NodeList, added: bool) { + for idx in 0..nodes.length() { + let Some(node) = nodes.item(idx) else { continue }; + self.handle_node(node, added); + } + } + + fn handle_node(&mut self, node: Node, added: bool) { + let Ok(element) = node.dyn_into::() else { return }; + + for root in collect_roots(&element) { + if added { + self.init_container(root); + } else { + self.remove_container(&root); + } + } + } + + fn ensure_key(&mut self, root: &Element) -> String { + if let Some(key) = root.get_attribute(OTP_KEY_ATTR) { + return key; + } + + let key = format!("otp-{}", self.next_key); + self.next_key += 1; + let _ = root.set_attribute(OTP_KEY_ATTR, &key); + key + } + + fn observe_dom(&mut self) { + let Some(document) = document() else { return }; + let Some(body) = document.body() else { return }; + + let callback = Closure::wrap(Box::new(move |records: js_sys::Array, _observer: MutationObserver| { + MANAGER.with(|manager| { + if let Some(manager) = manager.borrow_mut().as_mut() { + manager.handle_mutations(records); + } + }); + }) as Box); + + let Ok(observer) = MutationObserver::new(callback.as_ref().unchecked_ref()) else { + return; + }; + + let options = MutationObserverInit::new(); + options.set_child_list(true); + options.set_subtree(true); + + if observer.observe_with_options(body.as_ref(), &options).is_err() { + return; + } + + self.observer = Some(observer); + self.observer_callback = Some(callback); + } +} + +impl Drop for OtpManager { + fn drop(&mut self) { + if let Some(observer) = &self.observer { + observer.disconnect(); + } + } +} + +struct OtpController { + _dom: Rc, + _listeners: Vec, +} + +impl OtpController { + fn new(root: Element) -> Option { + let input = find_input(&root)?; + let dom = Rc::new(OtpDom::new(input, collect_slots(&root))); + let listeners = register_listeners(&dom); + update(&dom); + Some(Self { _dom: dom, _listeners: listeners }) + } +} + +struct OtpDom { + input: HtmlInputElement, + slots: Vec, + max_len: usize, +} + +impl OtpDom { + fn new(input: HtmlInputElement, mut slots: Vec) -> Self { + slots.sort_by_key(|slot| slot.index); + let max_len = input + .get_attribute("maxlength") + .and_then(|value| value.parse::().ok()) + .unwrap_or(6); + + Self { input, slots, max_len } + } +} + +#[derive(Clone)] +struct OtpSlot { + index: usize, + slot: Element, + char_el: Option, + caret_el: Option, +} + +struct Listener { + target: EventTarget, + event: &'static str, + callback: Closure, +} + +impl Drop for Listener { + fn drop(&mut self) { + let _ = self + .target + .remove_event_listener_with_callback(self.event, self.callback.as_ref().unchecked_ref()); + } +} + +fn document() -> Option { + web_sys::window().and_then(|window| window.document()) +} + +fn find_input(root: &Element) -> Option { + let input = root.query_selector(OTP_INPUT_SELECTOR).ok().flatten()?; + input.dyn_into::().ok() +} + +fn collect_slots(root: &Element) -> Vec { + let Ok(nodes) = root.query_selector_all(OTP_SLOT_SELECTOR) else { + return Vec::new(); + }; + + let mut slots = Vec::new(); + for idx in 0..nodes.length() { + let Some(node) = nodes.item(idx) else { continue }; + let Ok(slot) = node.dyn_into::() else { continue }; + let Some(index) = slot.get_attribute("data-otp-index").and_then(|value| value.parse::().ok()) else { + continue; + }; + + let char_el = slot.query_selector(OTP_CHAR_SELECTOR).ok().flatten(); + let caret_el = slot + .query_selector(OTP_CARET_SELECTOR) + .ok() + .flatten() + .and_then(|caret| caret.dyn_into::().ok()); + + slots.push(OtpSlot { index, slot, char_el, caret_el }); + } + + slots +} + +fn collect_roots(element: &Element) -> Vec { + let mut roots = Vec::new(); + + if element.matches(OTP_ROOT_SELECTOR).ok() == Some(true) { + roots.push(element.clone()); + } + + let Ok(nodes) = element.query_selector_all(OTP_ROOT_SELECTOR) else { + return roots; + }; + + for idx in 0..nodes.length() { + let Some(node) = nodes.item(idx) else { continue }; + let Ok(root) = node.dyn_into::() else { continue }; + roots.push(root); + } + + roots +} + +fn register_listeners(dom: &Rc) -> Vec { + let mut listeners = Vec::new(); + listeners.extend(register_slot_clicks(dom)); + listeners.extend(register_input_listeners(dom)); + listeners +} + +fn register_slot_clicks(dom: &Rc) -> Vec { + dom.slots + .iter() + .filter_map(|slot| { + let dom = Rc::clone(dom); + add_listener(slot.slot.clone().unchecked_into(), "click", move |_| { + if dom.input.disabled() { + return; + } + + let _ = dom.input.focus(); + }) + }) + .collect() +} + +fn register_input_listeners(dom: &Rc) -> Vec { + let target: EventTarget = dom.input.clone().unchecked_into(); + let mut listeners = Vec::new(); + + listeners.extend([ + { + let dom = Rc::clone(dom); + add_listener(target.clone(), "beforeinput", move |event| { + filter_input(event, &dom); + }) + }, + { + let dom = Rc::clone(dom); + add_listener(target.clone(), "input", move |_| { + update(&dom); + }) + }, + { + let dom = Rc::clone(dom); + add_listener(target.clone(), "keydown", move |_| { + defer({ + let dom = Rc::clone(&dom); + move || update(&dom) + }); + }) + }, + { + let dom = Rc::clone(dom); + add_listener(target.clone(), "focus", move |_| { + defer({ + let dom = Rc::clone(&dom); + move || { + move_cursor_to_end(&dom.input); + update(&dom); + } + }); + }) + }, + { + let dom = Rc::clone(dom); + add_listener(target, "blur", move |_| { + update(&dom); + }) + }, + ]); + + listeners.into_iter().flatten().collect() +} + +fn add_listener(target: EventTarget, event: &'static str, handler: F) -> Option +where + F: FnMut(Event) + 'static, +{ + let callback = Closure::wrap(Box::new(handler) as Box); + target.add_event_listener_with_callback(event, callback.as_ref().unchecked_ref()).ok()?; + Some(Listener { target, event, callback }) +} + +fn filter_input(event: Event, _dom: &Rc) { + let Some(input_event) = event.dyn_ref::() else { + return; + }; + + if input_event.input_type() != "insertText" { + return; + } + + let Some(data) = input_event.data() else { return }; + if data.chars().all(|ch| ch.is_ascii_digit()) { + return; + } + + input_event.prevent_default(); +} + +fn move_cursor_to_end(input: &HtmlInputElement) { + let len = input.value().chars().count() as u32; + let _ = input.set_selection_range(len, len); +} + +fn defer(callback: F) +where + F: FnOnce() + 'static, +{ + let Some(window) = web_sys::window() else { + callback(); + return; + }; + + let callback = Closure::once_into_js(callback); + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), 0); +} + +fn update(dom: &OtpDom) { + let value: Vec = dom.input.value().chars().collect(); + let input_element: Element = dom.input.clone().unchecked_into(); + let focused = document() + .and_then(|document| document.active_element()) + .is_some_and(|active| active == input_element); + + let selection = if focused { + dom.input + .selection_start() + .ok() + .flatten() + .map_or(0, |position| position as usize) + } else { + usize::MAX + }; + + for slot in &dom.slots { + let ch = value.get(slot.index).copied().unwrap_or_default().to_string(); + let is_active = focused + && (selection == slot.index || (selection >= value.len() && slot.index == value.len() && value.len() < dom.max_len)); + + if let Some(char_el) = &slot.char_el { + char_el.set_text_content(Some(&ch)); + } + + let _ = slot.slot.set_attribute("data-active", if is_active { "true" } else { "false" }); + + if let Some(caret_el) = &slot.caret_el { + let display = if is_active && ch.is_empty() { "flex" } else { "none" }; + let _ = caret_el.style().set_property("display", display); + } + } +} diff --git a/app_crates/registry/src/ui/input_otp.rs b/app_crates/registry/src/ui/input_otp.rs index bb6a556..c31ec16 100644 --- a/app_crates/registry/src/ui/input_otp.rs +++ b/app_crates/registry/src/ui/input_otp.rs @@ -2,6 +2,8 @@ use icons::Minus; use leptos::prelude::*; use tw_merge::*; +#[cfg(target_arch = "wasm32")] +use crate::hooks::use_input_otp; use crate::hooks::use_random::use_random_id; /* ========================================================== */ @@ -16,6 +18,9 @@ pub fn InputOTP( #[prop(optional, into)] value: String, #[prop(optional, into)] class: String, ) -> impl IntoView { + #[cfg(target_arch = "wasm32")] + use_input_otp::init(); + let id = use_random_id(); let container_id = format!("otp_{}", id); let class = tw_merge!("relative flex items-center gap-2 has-[:disabled]:opacity-50", class); @@ -32,7 +37,6 @@ pub fn InputOTP( prop:value=value class="sr-only" /> -