From df949090b2ea8e10229db7c06f2f4cddb945ff89 Mon Sep 17 00:00:00 2001 From: Evan Almloff Date: Mon, 4 May 2026 13:54:08 -0500 Subject: [PATCH 01/19] add otp component --- preview/src/components/mod.rs | 1 + preview/src/components/otp/component.json | 13 + preview/src/components/otp/component.rs | 74 +++ preview/src/components/otp/docs.md | 27 + preview/src/components/otp/mod.rs | 2 + preview/src/components/otp/style.css | 106 ++++ .../src/components/otp/variants/main/mod.rs | 27 + primitives/src/lib.rs | 1 + primitives/src/otp.rs | 506 ++++++++++++++++++ 9 files changed, 757 insertions(+) create mode 100644 preview/src/components/otp/component.json create mode 100644 preview/src/components/otp/component.rs create mode 100644 preview/src/components/otp/docs.md create mode 100644 preview/src/components/otp/mod.rs create mode 100644 preview/src/components/otp/style.css create mode 100644 preview/src/components/otp/variants/main/mod.rs create mode 100644 primitives/src/otp.rs diff --git a/preview/src/components/mod.rs b/preview/src/components/mod.rs index 910419029..be27b260f 100644 --- a/preview/src/components/mod.rs +++ b/preview/src/components/mod.rs @@ -140,6 +140,7 @@ examples!( label, menubar, navbar, + otp, pagination, popover, progress, diff --git a/preview/src/components/otp/component.json b/preview/src/components/otp/component.json new file mode 100644 index 000000000..a963a6b0b --- /dev/null +++ b/preview/src/components/otp/component.json @@ -0,0 +1,13 @@ +{ + "name": "otp", + "description": "An accessible, composable one-time-password input.", + "authors": ["Evan Almloff"], + "exclude": ["variants", "docs.md", "component.json"], + "cargoDependencies": [ + { + "name": "dioxus-primitives", + "git": "https://github.com/DioxusLabs/components" + } + ], + "globalAssets": ["../../../assets/dx-components-theme.css"] +} diff --git a/preview/src/components/otp/component.rs b/preview/src/components/otp/component.rs new file mode 100644 index 000000000..66ecc884a --- /dev/null +++ b/preview/src/components/otp/component.rs @@ -0,0 +1,74 @@ +use dioxus::prelude::*; +use dioxus_primitives::otp::{ + self, OneTimePasswordGroupProps, OneTimePasswordInputProps, OneTimePasswordSeparatorProps, + OneTimePasswordSlotProps, +}; + +#[component] +pub fn OneTimePasswordInput(props: OneTimePasswordInputProps) -> Element { + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + otp::OneTimePasswordInput { + class: "dx-otp", + value: props.value, + default_value: props.default_value, + maxlength: props.maxlength, + pattern: props.pattern, + inputmode: props.inputmode, + autocomplete: props.autocomplete, + disabled: props.disabled, + required: props.required, + name: props.name, + on_value_change: props.on_value_change, + on_complete: props.on_complete, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn OneTimePasswordGroup(props: OneTimePasswordGroupProps) -> Element { + rsx! { + otp::OneTimePasswordGroup { + class: "dx-otp-group", + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn OneTimePasswordSlot(props: OneTimePasswordSlotProps) -> Element { + rsx! { + otp::OneTimePasswordSlot { + class: "dx-otp-slot", + index: props.index, + attributes: props.attributes, + span { class: "dx-otp-caret", aria_hidden: "true" } + {props.children} + } + } +} + +#[component] +pub fn OneTimePasswordSeparator(props: OneTimePasswordSeparatorProps) -> Element { + rsx! { + otp::OneTimePasswordSeparator { + class: "dx-otp-separator", + attributes: props.attributes, + svg { + width: "10", + height: "10", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + line { x1: "5", y1: "12", x2: "19", y2: "12" } + } + {props.children} + } + } +} diff --git a/preview/src/components/otp/docs.md b/preview/src/components/otp/docs.md new file mode 100644 index 000000000..aa1f747e7 --- /dev/null +++ b/preview/src/components/otp/docs.md @@ -0,0 +1,27 @@ +The OneTimePasswordInput component is used to capture a short code (typically a 6-digit +authentication code) into a row of discrete slots. It is built on a single accessible +`` element so paste, browser autofill (`autocomplete="one-time-code"`), IME +composition, and screen readers all continue to work. + +## Component Structure + +```rust +// The wrapper holds the hidden input and provides shared state to all slots. +OneTimePasswordInput { + maxlength: 6, + // A visual grouping of contiguous slots. + OneTimePasswordGroup { + // Each slot displays the character at its `index`. + OneTimePasswordSlot { index: 0 } + OneTimePasswordSlot { index: 1 } + OneTimePasswordSlot { index: 2 } + } + // Decorative separator placed between groups. + OneTimePasswordSeparator {} + OneTimePasswordGroup { + OneTimePasswordSlot { index: 3 } + OneTimePasswordSlot { index: 4 } + OneTimePasswordSlot { index: 5 } + } +} +``` diff --git a/preview/src/components/otp/mod.rs b/preview/src/components/otp/mod.rs new file mode 100644 index 000000000..2590c0132 --- /dev/null +++ b/preview/src/components/otp/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; diff --git a/preview/src/components/otp/style.css b/preview/src/components/otp/style.css new file mode 100644 index 000000000..e96ed0604 --- /dev/null +++ b/preview/src/components/otp/style.css @@ -0,0 +1,106 @@ +/* One-Time Password Input — shadcn-style */ + +.dx-otp { + display: flex; + align-items: center; + font-family: inherit; + gap: 0.5rem; +} + +.dx-otp[data-disabled="true"] { + cursor: not-allowed; + opacity: 0.5; +} + +.dx-otp[data-disabled="true"] input { + cursor: not-allowed; +} + +.dx-otp-group { + display: flex; + align-items: center; +} + +.dx-otp-slot { + position: relative; + display: flex; + width: 2.25rem; + height: 2.25rem; + box-sizing: border-box; + align-items: center; + justify-content: center; + border-top: 1px solid var(--primary-color-7); + border-right: 1px solid var(--primary-color-7); + border-bottom: 1px solid var(--primary-color-7); + background-color: var(--light, var(--primary-color)) + var(--dark, color-mix(in oklab, var(--primary-color-7) 30%, transparent)); + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 5%); + color: var(--secondary-color-1); + font-size: 0.875rem; + font-variant-numeric: tabular-nums; + font-weight: 500; + line-height: 1.25rem; + outline: none; + transition: color 100ms ease-out, background-color 100ms ease-out, + border-color 100ms ease-out, box-shadow 100ms ease-out; +} + +.dx-otp-group .dx-otp-slot:first-child { + border-left: 1px solid var(--primary-color-7); + border-bottom-left-radius: 0.375rem; + border-top-left-radius: 0.375rem; +} + +.dx-otp-group .dx-otp-slot:last-child { + border-bottom-right-radius: 0.375rem; + border-top-right-radius: 0.375rem; +} + +.dx-otp-slot[data-active="true"] { + z-index: 10; + box-shadow: 0 0 0 2px var(--focused-border-color); +} + +/* Fake blinking caret — only visible in the active, empty slot */ +.dx-otp-caret { + position: absolute; + display: none; + align-items: center; + justify-content: center; + inset: 0; + pointer-events: none; +} + +.dx-otp-caret::after { + display: block; + width: 1px; + height: 1rem; + animation: dx-otp-caret-blink 1s ease-out infinite; + background-color: var(--secondary-color-1); + content: ""; +} + +.dx-otp-slot[data-active="true"][data-empty="true"] .dx-otp-caret { + display: flex; +} + +@keyframes dx-otp-caret-blink { + 0%, + 70%, + 100% { + opacity: 1; + } + + 20%, + 50% { + opacity: 0; + } +} + +.dx-otp-separator { + display: flex; + align-items: center; + justify-content: center; + color: var(--secondary-color-4); + pointer-events: none; +} diff --git a/preview/src/components/otp/variants/main/mod.rs b/preview/src/components/otp/variants/main/mod.rs new file mode 100644 index 000000000..80470e837 --- /dev/null +++ b/preview/src/components/otp/variants/main/mod.rs @@ -0,0 +1,27 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + let mut value = use_signal(String::new); + rsx! { + OneTimePasswordInput { + maxlength: 6usize, + value: value(), + pattern: "[0-9]*", + on_value_change: move |v| value.set(v), + aria_label: "One-time password", + OneTimePasswordGroup { + OneTimePasswordSlot { index: 0usize } + OneTimePasswordSlot { index: 1usize } + OneTimePasswordSlot { index: 2usize } + } + OneTimePasswordSeparator {} + OneTimePasswordGroup { + OneTimePasswordSlot { index: 3usize } + OneTimePasswordSlot { index: 4usize } + OneTimePasswordSlot { index: 5usize } + } + } + } +} diff --git a/primitives/src/lib.rs b/primitives/src/lib.rs index b76bea888..00f699baa 100644 --- a/primitives/src/lib.rs +++ b/primitives/src/lib.rs @@ -34,6 +34,7 @@ pub mod menubar; mod move_interaction; #[cfg(feature = "router")] pub mod navbar; +pub mod otp; mod pointer; pub mod popover; mod portal; diff --git a/primitives/src/otp.rs b/primitives/src/otp.rs new file mode 100644 index 000000000..3129259ee --- /dev/null +++ b/primitives/src/otp.rs @@ -0,0 +1,506 @@ +//! Defines the [`OneTimePasswordInput`] component and its sub-components for building +//! accessible, composable one-time-password (OTP) inputs. + +use crate::{use_controlled, use_unique_id}; +use dioxus::prelude::*; + +#[derive(Clone, Copy)] +struct OtpCtx { + value: Memo, + disabled: ReadSignal, + active_index: Memo>, +} + +#[derive(Clone)] +struct PatternMatcher { + pattern: String, + class: Option>, +} + +#[derive(Clone, PartialEq)] +enum CharMatcher { + Char(char), + Range(char, char), + Digit, +} + +impl PatternMatcher { + fn new(pattern: &str) -> Self { + Self { + pattern: pattern.to_string(), + class: parse_full_value_char_class(pattern), + } + } + + fn matches(&self, value: &str) -> bool { + value.is_empty() + || self.class.as_ref().map_or(true, |class| { + value.chars().all(|c| { + class.iter().any(|matcher| match matcher { + CharMatcher::Char(expected) => *expected == c, + CharMatcher::Range(start, end) => *start <= c && c <= *end, + CharMatcher::Digit => c.is_ascii_digit(), + }) + }) + }) + } +} + +impl PartialEq for PatternMatcher { + fn eq(&self, other: &Self) -> bool { + self.pattern == other.pattern + } +} + +fn parse_full_value_char_class(pattern: &str) -> Option> { + let pattern = pattern + .strip_prefix('^') + .unwrap_or(pattern) + .strip_suffix('$') + .unwrap_or(pattern); + let pattern = pattern + .strip_suffix('*') + .or_else(|| pattern.strip_suffix('+')) + .unwrap_or(pattern); + + match pattern { + r"\d" => Some(vec![CharMatcher::Digit]), + _ if pattern.starts_with('[') && pattern.ends_with(']') => { + parse_char_class(&pattern[1..pattern.len() - 1]) + } + _ => None, + } +} + +fn parse_char_class(class: &str) -> Option> { + let mut chars = class.chars().peekable(); + let mut matchers = Vec::new(); + + while let Some(start) = chars.next() { + let start = if start == '\\' { + match chars.next()? { + 'd' => { + matchers.push(CharMatcher::Digit); + continue; + } + escaped => escaped, + } + } else { + start + }; + + if chars.peek() == Some(&'-') { + chars.next(); + let end = chars.next()?; + matchers.push(CharMatcher::Range(start, end)); + } else { + matchers.push(CharMatcher::Char(start)); + } + } + + Some(matchers) +} + +/// The props for the [`OneTimePasswordInput`] component. +#[derive(Props, Clone, PartialEq)] +pub struct OneTimePasswordInputProps { + /// The controlled value of the OTP input. + pub value: ReadSignal>, + + /// The default value when uncontrolled. + #[props(default)] + pub default_value: String, + + /// The maximum number of characters the input accepts (the total number of slots). + pub maxlength: ReadSignal, + + /// HTML pattern attribute applied to the underlying input. Defaults to digits only. + #[props(default = ReadSignal::new(Signal::new(String::from("[0-9]*"))))] + pub pattern: ReadSignal, + + /// Hint for the on-screen keyboard. Defaults to `"numeric"`. + #[props(default = ReadSignal::new(Signal::new(String::from("numeric"))))] + pub inputmode: ReadSignal, + + /// Autocomplete hint applied to the underlying input. Defaults to `"one-time-code"`. + #[props(default = ReadSignal::new(Signal::new(String::from("one-time-code"))))] + pub autocomplete: ReadSignal, + + /// Whether the input is disabled. + #[props(default)] + pub disabled: ReadSignal, + + /// Whether the input is required in a form. + #[props(default)] + pub required: ReadSignal, + + /// The name attribute used for form submission. + #[props(default)] + pub name: ReadSignal, + + /// Callback fired whenever the value changes. + #[props(default)] + pub on_value_change: Callback, + + /// Callback fired when the value reaches `maxlength`. + #[props(default)] + pub on_complete: Callback, + + /// Additional attributes applied to the wrapper element. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// The children of the input — typically [`OneTimePasswordGroup`], [`OneTimePasswordSlot`], + /// and [`OneTimePasswordSeparator`] components. + pub children: Element, +} + +/// # OneTimePasswordInput +/// +/// The `OneTimePasswordInput` is the root of an OTP entry. It renders a single, accessible +/// `` element overlaid on top of its children so paste, autofill (`autocomplete="one-time-code"`), +/// IME composition, and screen readers continue to work, while child [`OneTimePasswordSlot`]s render +/// the visual representation of each character. +/// +/// ## Example +/// +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::otp::{ +/// OneTimePasswordInput, OneTimePasswordGroup, OneTimePasswordSlot, OneTimePasswordSeparator, +/// }; +/// +/// #[component] +/// fn Demo() -> Element { +/// rsx! { +/// OneTimePasswordInput { maxlength: 6usize, +/// OneTimePasswordGroup { +/// OneTimePasswordSlot { index: 0usize } +/// OneTimePasswordSlot { index: 1usize } +/// OneTimePasswordSlot { index: 2usize } +/// } +/// OneTimePasswordSeparator {} +/// OneTimePasswordGroup { +/// OneTimePasswordSlot { index: 3usize } +/// OneTimePasswordSlot { index: 4usize } +/// OneTimePasswordSlot { index: 5usize } +/// } +/// } +/// } +/// } +/// ``` +/// +/// ## Styling +/// +/// The wrapper sets the following data attributes: +/// - `data-disabled`: `true` or `false` depending on the `disabled` prop. +/// - `data-focused`: `true` or `false` depending on whether the underlying input has focus. +#[component] +pub fn OneTimePasswordInput(props: OneTimePasswordInputProps) -> Element { + let maxlength = props.maxlength; + let pattern = props.pattern; + let on_complete = props.on_complete; + let pattern_matcher = use_memo(move || PatternMatcher::new(&pattern())); + + let (value, set_value) = + use_controlled(props.value, props.default_value, props.on_value_change); + + let input_id = use_unique_id(); + let mut is_focused = use_signal(|| false); + let mut cursor = use_signal(|| 0usize); + let (input_label_attributes, wrapper_attributes): (Vec<_>, Vec<_>) = + props.attributes.iter().cloned().partition(|attr| { + matches!( + attr.name, + "aria-label" | "aria_label" | "aria-labelledby" | "aria_labelledby" + ) + }); + + let active_index = use_memo(move || { + if !is_focused() { + return None; + } + let max = maxlength(); + if max == 0 { + return None; + } + Some(cursor().min(max - 1)) + }); + + use_context_provider(|| OtpCtx { + value, + disabled: props.disabled, + active_index, + }); + + rsx! { + div { + role: "group", + position: "relative", + "data-disabled": props.disabled, + "data-focused": is_focused, + ..wrapper_attributes, + + {props.children} + + input { + id: input_id, + r#type: "text", + inputmode: props.inputmode, + autocomplete: props.autocomplete, + pattern, + name: props.name, + disabled: props.disabled, + aria_required: props.required, + required: props.required, + maxlength: maxlength() as i64, + value, + + style: "position:absolute;top:0;left:0;right:0;bottom:0;width:100%;height:100%;opacity:1;color:transparent;background:transparent;caret-color:transparent;outline:none;border:none;padding:0;margin:0;text-align:center;font-family:inherit;font-size:inherit;cursor:text;user-select:none;", + + onkeydown: move |e: Event| { + if (props.disabled)() { + return; + } + let key = e.key(); + let max = maxlength(); + if max == 0 { + return; + } + let mods = e.modifiers(); + let mut chars: Vec = value.read().chars().collect(); + let mut new_cursor = cursor(); + let mut value_changed = false; + + match key { + Key::ArrowLeft => { + new_cursor = new_cursor.saturating_sub(1); + e.prevent_default(); + } + Key::ArrowRight => { + if new_cursor < max { + new_cursor += 1; + } + e.prevent_default(); + } + Key::Home => { + new_cursor = 0; + e.prevent_default(); + } + Key::End => { + new_cursor = chars.len(); + e.prevent_default(); + } + Key::Backspace => { + e.prevent_default(); + if mods.ctrl() || mods.meta() { + if !chars.is_empty() { + chars.clear(); + new_cursor = 0; + value_changed = true; + } + } else { + let effective = new_cursor.min(chars.len()); + if effective > 0 { + chars.remove(effective - 1); + new_cursor = effective - 1; + value_changed = true; + } + } + } + Key::Delete => { + e.prevent_default(); + if new_cursor < chars.len() { + chars.remove(new_cursor); + value_changed = true; + } + } + Key::Character(ref s) + if s.chars().count() == 1 + && !mods.ctrl() + && !mods.meta() + && !mods.alt() => + { + e.prevent_default(); + let insert_at = new_cursor.min(chars.len()); + if insert_at < max { + let c = s.chars().next().unwrap(); + let mut next_chars = chars.clone(); + if insert_at < next_chars.len() { + next_chars[insert_at] = c; + } else { + next_chars.push(c); + } + if next_chars.len() > max { + next_chars.truncate(max); + } + let next_value: String = next_chars.iter().copied().collect(); + if !pattern_matcher.read().matches(&next_value) { + return; + } + if insert_at < chars.len() { + chars[insert_at] = c; + } else { + chars.push(c); + } + if chars.len() > max { + chars.truncate(max); + } + new_cursor = (insert_at + 1).min(max); + value_changed = true; + } + } + _ => {} + } + + if value_changed { + let new_value: String = chars.into_iter().collect(); + set_value.call(new_value.clone()); + if new_value.chars().count() == max { + on_complete.call(new_value); + } + } + if new_cursor != cursor() { + cursor.set(new_cursor); + } + }, + + oninput: move |e| { + // Catches paste, autofill, IME, and on-screen keyboards. + let raw = e.value(); + let max = maxlength(); + let filtered: String = raw.chars().take(max).collect(); + if !pattern_matcher.read().matches(&filtered) { + return; + } + let len = filtered.chars().count(); + if filtered != *value.read() { + set_value.call(filtered.clone()); + if max > 0 && len == max { + on_complete.call(filtered); + } + } + cursor.set(len); + }, + + onfocus: move |_| { + is_focused.set(true); + cursor.set(value.read().chars().count()); + }, + onblur: move |_| is_focused.set(false), + ..input_label_attributes, + } + } + } +} + +/// The props for the [`OneTimePasswordGroup`] component. +#[derive(Props, Clone, PartialEq)] +pub struct OneTimePasswordGroupProps { + /// Additional attributes applied to the group element. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// The slots inside the group. + pub children: Element, +} + +/// # OneTimePasswordGroup +/// +/// A visual grouping of [`OneTimePasswordSlot`]s. Used to render contiguous slots +/// separated by [`OneTimePasswordSeparator`]s. +#[component] +pub fn OneTimePasswordGroup(props: OneTimePasswordGroupProps) -> Element { + rsx! { + div { + role: "presentation", + ..props.attributes, + {props.children} + } + } +} + +/// The props for the [`OneTimePasswordSlot`] component. +#[derive(Props, Clone, PartialEq)] +pub struct OneTimePasswordSlotProps { + /// The position of this slot in the value (zero-based). + pub index: ReadSignal, + + /// Additional attributes applied to the slot element. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// Optional children rendered after the character (for example, a custom caret element). + /// The current character is exposed via the `data-char` attribute. + pub children: Element, +} + +/// # OneTimePasswordSlot +/// +/// A single slot within a [`OneTimePasswordInput`]. Renders the character at `index` from the +/// shared value. Must be used inside a [`OneTimePasswordInput`]. +/// +/// ## Styling +/// +/// The slot element exposes: +/// - `data-active`: `true` when this slot is the next one to receive input. +/// - `data-empty`: `true` when no character has been entered at this position. +/// - `data-disabled`: mirrors the parent's disabled state. +/// - `data-char`: the current character at this position (empty when none). +#[component] +pub fn OneTimePasswordSlot(props: OneTimePasswordSlotProps) -> Element { + let ctx: OtpCtx = use_context(); + let index = props.index; + + let char_at = use_memo(move || { + ctx.value + .read() + .chars() + .nth(index()) + .map(|c| c.to_string()) + .unwrap_or_default() + }); + let is_active = use_memo(move || ctx.active_index.cloned() == Some(index())); + let is_empty = use_memo(move || char_at.read().is_empty()); + + rsx! { + div { + role: "presentation", + aria_hidden: "true", + "data-active": is_active, + "data-empty": is_empty, + "data-disabled": ctx.disabled, + "data-char": char_at, + ..props.attributes, + + {char_at} + {props.children} + } + } +} + +/// The props for the [`OneTimePasswordSeparator`] component. +#[derive(Props, Clone, PartialEq)] +pub struct OneTimePasswordSeparatorProps { + /// Additional attributes applied to the separator element. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// Optional children that replace the default separator content. + pub children: Element, +} + +/// # OneTimePasswordSeparator +/// +/// A purely decorative separator placed between [`OneTimePasswordGroup`]s. +#[component] +pub fn OneTimePasswordSeparator(props: OneTimePasswordSeparatorProps) -> Element { + rsx! { + div { + role: "separator", + aria_hidden: "true", + ..props.attributes, + {props.children} + } + } +} From 57968bf5067043b8eb3c7105e9a1fffd9fcf20e1 Mon Sep 17 00:00:00 2001 From: Evan Almloff Date: Mon, 11 May 2026 10:20:27 -0500 Subject: [PATCH 02/19] move away from just regex --- playwright/otp.spec.ts | 18 +++ preview/src/components/otp/component.rs | 5 +- preview/src/components/otp/docs.md | 3 + .../src/components/otp/variants/main/mod.rs | 3 +- primitives/src/otp.rs | 147 ++++-------------- 5 files changed, 61 insertions(+), 115 deletions(-) create mode 100644 playwright/otp.spec.ts diff --git a/playwright/otp.spec.ts b/playwright/otp.spec.ts new file mode 100644 index 000000000..c81d25b64 --- /dev/null +++ b/playwright/otp.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from '@playwright/test'; + +test('otp', async ({ page }) => { + await page.goto('http://127.0.0.1:8080/component/?name=otp&', { timeout: 20 * 60 * 1000 }); + + const input = page.getByRole('textbox', { name: 'One-time password' }); + await expect(input).toBeVisible(); + + await input.focus(); + await page.keyboard.type('123456'); + await expect(page.locator('#otp-value')).toHaveText('123456'); + + await page.keyboard.press('Backspace'); + await expect(page.locator('#otp-value')).toHaveText('12345'); + + await page.keyboard.press('a'); + await expect(page.locator('#otp-value')).toHaveText('12345'); +}); diff --git a/preview/src/components/otp/component.rs b/preview/src/components/otp/component.rs index 66ecc884a..e95ad7e66 100644 --- a/preview/src/components/otp/component.rs +++ b/preview/src/components/otp/component.rs @@ -13,12 +13,15 @@ pub fn OneTimePasswordInput(props: OneTimePasswordInputProps) -> Element { value: props.value, default_value: props.default_value, maxlength: props.maxlength, - pattern: props.pattern, inputmode: props.inputmode, autocomplete: props.autocomplete, disabled: props.disabled, required: props.required, name: props.name, + id: props.id, + aria_label: props.aria_label, + aria_labelledby: props.aria_labelledby, + validate: props.validate, on_value_change: props.on_value_change, on_complete: props.on_complete, attributes: props.attributes, diff --git a/preview/src/components/otp/docs.md b/preview/src/components/otp/docs.md index aa1f747e7..9eba1512a 100644 --- a/preview/src/components/otp/docs.md +++ b/preview/src/components/otp/docs.md @@ -9,6 +9,9 @@ composition, and screen readers all continue to work. // The wrapper holds the hidden input and provides shared state to all slots. OneTimePasswordInput { maxlength: 6, + aria_label: "One-time password", + // Reject anything that isn't a digit (paste, autofill, and keystrokes). + validate: |s: String| s.chars().all(|c| c.is_ascii_digit()), // A visual grouping of contiguous slots. OneTimePasswordGroup { // Each slot displays the character at its `index`. diff --git a/preview/src/components/otp/variants/main/mod.rs b/preview/src/components/otp/variants/main/mod.rs index 80470e837..8e3f4512e 100644 --- a/preview/src/components/otp/variants/main/mod.rs +++ b/preview/src/components/otp/variants/main/mod.rs @@ -8,7 +8,7 @@ pub fn Demo() -> Element { OneTimePasswordInput { maxlength: 6usize, value: value(), - pattern: "[0-9]*", + validate: |s: String| s.chars().all(|c| c.is_ascii_digit()), on_value_change: move |v| value.set(v), aria_label: "One-time password", OneTimePasswordGroup { @@ -23,5 +23,6 @@ pub fn Demo() -> Element { OneTimePasswordSlot { index: 5usize } } } + div { id: "otp-value", "{value}" } } } diff --git a/primitives/src/otp.rs b/primitives/src/otp.rs index 3129259ee..7f708dc0c 100644 --- a/primitives/src/otp.rs +++ b/primitives/src/otp.rs @@ -1,7 +1,7 @@ //! Defines the [`OneTimePasswordInput`] component and its sub-components for building //! accessible, composable one-time-password (OTP) inputs. -use crate::{use_controlled, use_unique_id}; +use crate::{use_controlled, use_id_or, use_unique_id}; use dioxus::prelude::*; #[derive(Clone, Copy)] @@ -11,96 +11,6 @@ struct OtpCtx { active_index: Memo>, } -#[derive(Clone)] -struct PatternMatcher { - pattern: String, - class: Option>, -} - -#[derive(Clone, PartialEq)] -enum CharMatcher { - Char(char), - Range(char, char), - Digit, -} - -impl PatternMatcher { - fn new(pattern: &str) -> Self { - Self { - pattern: pattern.to_string(), - class: parse_full_value_char_class(pattern), - } - } - - fn matches(&self, value: &str) -> bool { - value.is_empty() - || self.class.as_ref().map_or(true, |class| { - value.chars().all(|c| { - class.iter().any(|matcher| match matcher { - CharMatcher::Char(expected) => *expected == c, - CharMatcher::Range(start, end) => *start <= c && c <= *end, - CharMatcher::Digit => c.is_ascii_digit(), - }) - }) - }) - } -} - -impl PartialEq for PatternMatcher { - fn eq(&self, other: &Self) -> bool { - self.pattern == other.pattern - } -} - -fn parse_full_value_char_class(pattern: &str) -> Option> { - let pattern = pattern - .strip_prefix('^') - .unwrap_or(pattern) - .strip_suffix('$') - .unwrap_or(pattern); - let pattern = pattern - .strip_suffix('*') - .or_else(|| pattern.strip_suffix('+')) - .unwrap_or(pattern); - - match pattern { - r"\d" => Some(vec![CharMatcher::Digit]), - _ if pattern.starts_with('[') && pattern.ends_with(']') => { - parse_char_class(&pattern[1..pattern.len() - 1]) - } - _ => None, - } -} - -fn parse_char_class(class: &str) -> Option> { - let mut chars = class.chars().peekable(); - let mut matchers = Vec::new(); - - while let Some(start) = chars.next() { - let start = if start == '\\' { - match chars.next()? { - 'd' => { - matchers.push(CharMatcher::Digit); - continue; - } - escaped => escaped, - } - } else { - start - }; - - if chars.peek() == Some(&'-') { - chars.next(); - let end = chars.next()?; - matchers.push(CharMatcher::Range(start, end)); - } else { - matchers.push(CharMatcher::Char(start)); - } - } - - Some(matchers) -} - /// The props for the [`OneTimePasswordInput`] component. #[derive(Props, Clone, PartialEq)] pub struct OneTimePasswordInputProps { @@ -114,10 +24,6 @@ pub struct OneTimePasswordInputProps { /// The maximum number of characters the input accepts (the total number of slots). pub maxlength: ReadSignal, - /// HTML pattern attribute applied to the underlying input. Defaults to digits only. - #[props(default = ReadSignal::new(Signal::new(String::from("[0-9]*"))))] - pub pattern: ReadSignal, - /// Hint for the on-screen keyboard. Defaults to `"numeric"`. #[props(default = ReadSignal::new(Signal::new(String::from("numeric"))))] pub inputmode: ReadSignal, @@ -138,6 +44,25 @@ pub struct OneTimePasswordInputProps { #[props(default)] pub name: ReadSignal, + /// Optional id for the inner ``. When omitted, a stable id is generated. + /// Use this to associate an external `