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! {
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! {
+
+ }
+}
+
+#[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
}
}
diff --git a/public/app_components/otp.js b/public/app_components/otp.js
deleted file mode 100644
index 14616a5..0000000
--- a/public/app_components/otp.js
+++ /dev/null
@@ -1,100 +0,0 @@
-(function () {
- function initContainer(root) {
- if (root.dataset.otpInit) return;
- root.dataset.otpInit = '1';
-
- var input = root.querySelector('input[data-otp-input]');
- if (!input) return;
-
- var maxLen = parseInt(input.getAttribute('maxlength') || '6', 10);
-
- function getSlots() {
- return Array.from(root.querySelectorAll('[data-otp-slot]')).sort(function (a, b) {
- return parseInt(a.dataset.otpIndex, 10) - parseInt(b.dataset.otpIndex, 10);
- });
- }
-
- function update() {
- var val = input.value;
- var focused = document.activeElement === input;
- var sel = focused ? (input.selectionStart || 0) : -1;
-
- getSlots().forEach(function (slot) {
- var idx = parseInt(slot.dataset.otpIndex, 10);
- var char = val[idx] || '';
-
- // Active = cursor is at this slot position (or at end when value not full)
- var isActive = focused && (
- sel === idx ||
- (sel >= val.length && idx === val.length && val.length < maxLen)
- );
-
- // Char display — span is pre-rendered by SSR, just update textContent
- var charEl = slot.querySelector('[data-otp-char]');
- if (charEl) charEl.textContent = char;
-
- // Active styling
- slot.dataset.active = isActive ? 'true' : 'false';
-
- // Caret visibility
- var caret = slot.querySelector('[data-otp-caret]');
- if (caret) {
- caret.style.display = (isActive && !char) ? 'flex' : 'none';
- }
- });
- }
-
- // Click on slot → focus input
- getSlots().forEach(function (slot) {
- slot.addEventListener('click', function () {
- if (!input.disabled) input.focus();
- });
- });
-
- // Digits only
- input.addEventListener('beforeinput', function (e) {
- if (e.inputType === 'insertText' && e.data && !/^\d+$/.test(e.data)) {
- e.preventDefault();
- }
- });
-
- input.addEventListener('input', update);
-
- input.addEventListener('keydown', function () {
- setTimeout(update, 0);
- });
-
- input.addEventListener('focus', function () {
- setTimeout(function () {
- var len = input.value.length;
- input.setSelectionRange(len, len);
- update();
- }, 0);
- });
-
- input.addEventListener('blur', update);
-
- update();
- }
-
- function initAll() {
- document.querySelectorAll('[data-otp-root]').forEach(initContainer);
- }
-
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', initAll);
- } else {
- initAll();
- }
-
- // Watch for dynamically added OTP components (e.g. after Leptos hydration)
- new MutationObserver(function (mutations) {
- mutations.forEach(function (m) {
- m.addedNodes.forEach(function (node) {
- if (node.nodeType !== 1) return;
- if (node.matches && node.matches('[data-otp-root]')) initContainer(node);
- if (node.querySelectorAll) node.querySelectorAll('[data-otp-root]').forEach(initContainer);
- });
- });
- }).observe(document.body, { childList: true, subtree: true });
-})();
diff --git a/public/registry/styles/default/demo_input_otp.md b/public/registry/styles/default/demo_input_otp.md
index 1116e51..468f4d9 100644
--- a/public/registry/styles/default/demo_input_otp.md
+++ b/public/registry/styles/default/demo_input_otp.md
@@ -25,21 +25,249 @@ ui add demo_input_otp
```rust
use leptos::prelude::*;
-use crate::components::ui::input_otp::{InputOTP, InputOTPGroup, InputOTPSlot};
+use crate::ui::button::Button;
+use crate::ui::input_otp::{InputOTP, InputOTPGroup, InputOTPSlot};
#[component]
pub fn DemoInputOtp() -> impl IntoView {
+ let show_dynamic = RwSignal::new(false);
+
+ view! {
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+}
+
+#[component]
+fn DemoSection(title: &'static str, description: &'static str, children: Children) -> impl IntoView {
+ view! {
+
+
+
+
{title}
+
{description}
+
+ {children()}
+
+
+ }
+}
+
+#[component]
+fn OtpCard(max_length: usize) -> impl IntoView {
+ let slow_caret = RwSignal::new(false);
+
view! {
-
-
-
-
-
-
-
-
+
+
+
+
+ {move || format!("{max_length} slots with active-state highlighting and native input behavior.")}
+
+
+
+
+
+ }
+}
+
+#[component]
+fn FilterCard() -> impl IntoView {
+ let rejected = RwSignal::new(String::from("none"));
+
+ view! {
+
+
+
+
+
"Last rejected input"
+
{move || rejected.get()}
+
+ "Blocked characters never reach the OTP slots, so the UI stays digit-only."
+
+
+
+ }
+}
+
+#[component]
+fn DynamicCard(show_dynamic: RwSignal) -> impl IntoView {
+ view! {
+
+
+
+
+ "The newly inserted OTP should respond immediately without a page refresh."
+
+
+
+
+ "Press the button to append a new OTP widget into the DOM."
+
+ }
+ }
+ >
+
+
+
+ }
+}
+
+#[component]
+fn OtpMarkup(max_length: usize, slow_caret: RwSignal