From 188e84d18f29e67f4c0ebbb9801925f51cb00eb2 Mon Sep 17 00:00:00 2001 From: krishpranav Date: Thu, 23 Apr 2026 18:46:10 +0530 Subject: [PATCH] input_otp: migrate controller to Rust and split demo blocks Replace the OTP JavaScript controller with a Rust/web_sys implementation and wire InputOTP to initialize it directly. Update the docs demo so each OTP scenario renders as an independent block with its own local Preview and Code controls, and bypass the shared demo wrapper toolbar for the OTP page so the layout matches the intended structure. --- Cargo.toml | 7 + .../components/static_demo_wrapper.rs | 5 + .../registry/src/demos/demo_input_otp.rs | 418 +++++++++++++++++- app_crates/registry/src/hooks/mod.rs | 1 + .../registry/src/hooks/use_input_otp.rs | 414 +++++++++++++++++ app_crates/registry/src/ui/input_otp.rs | 6 +- public/app_components/otp.js | 100 ----- .../registry/styles/default/demo_input_otp.md | 246 ++++++++++- public/registry/styles/default/input_otp.md | 8 +- public/registry/tree.md | 12 +- 10 files changed, 1096 insertions(+), 121 deletions(-) create mode 100644 app_crates/registry/src/hooks/use_input_otp.rs delete mode 100644 public/app_components/otp.js 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" /> -