shadcn-iced
Menu

Input OTP

Accessible one-time password component with copy paste functionality.

use crate::pages::DemoMessage;
use crate::Message;
use shadcn_iced::prelude::*;

fn demo(message: DemoMessage) -> Message {
    Message::Demo(message)
}

input_otp(6, &gallery.demo.otp, |v| demo(DemoMessage::Otp(v)))

Installation

Copy the component, and the components it uses, into src/ui/:

cargo shadcn-iced add input-otp

Or use it from the shadcn_iced crate:

use shadcn_iced::input_otp::*;
// or everything at once:
use shadcn_iced::prelude::*;

Copy the source of the component into src/ui/input_otp.rs (it uses shadcn_iced_core and, sometimes, other components):

crates/shadcn_iced/src/components/input_otp.rs
//! Accessible one-time password component with copy paste functionality.
//!
//! Port of <https://ui.shadcn.com/docs/components/input-otp>.
//!
//! ```no_run
//! # use shadcn_iced::input_otp::input_otp;
//! # #[derive(Clone)] enum Message { Code(String) }
//! # fn view(code: &str) -> iced::Element<'_, Message> {
//! input_otp(6, code, Message::Code).groups(&[3, 3]).into()
//! # }
//! ```
use shadcn_iced_core::theme::alpha;
use shadcn_iced_core::{Element, font, style, tokens};

use iced::advanced::layout::{self, Layout};
use iced::advanced::renderer;
use iced::advanced::text::{self as advanced_text, Renderer as _};
use iced::advanced::widget::{Id, Operation, Tree, Widget, operation, tree};
use iced::advanced::{Clipboard, Renderer as _, Shell};
use iced::time::Instant;
use iced::widget::text_input::{self, TextInput};
use iced::{
    Background, Border, Color, Event, Length, Pixels, Point, Rectangle, Size, Theme, alignment,
    border, mouse, window,
};

/// `size-8`
const SLOT: f32 = 32.0;
/// A `size-4` minus icon with a `gap-2` on each side.
const SEPARATOR: f32 = 32.0;

/// A one-time password input. See the [module documentation](self).
pub struct InputOtp<'a, Message> {
    input: TextInput<'a, Message>,
    value: String,
    length: usize,
    groups: Vec<usize>,
    invalid: bool,
    disabled: bool,
}

/// Creates an [`InputOtp`] with `length` slots. Extra characters are
/// dropped.
pub fn input_otp<'a, Message: Clone + 'a>(
    length: usize,
    value: &str,
    on_input: impl Fn(String) -> Message + 'a,
) -> InputOtp<'a, Message> {
    let value: String = value.chars().take(length).collect();

    InputOtp {
        input: TextInput::new("", &value)
            .on_input(move |value| {
                on_input(
                    value
                        .chars()
                        .filter(|c| !c.is_whitespace())
                        .take(length)
                        .collect(),
                )
            })
            .size(1)
            .padding(0)
            .style(|_, _| text_input::Style {
                background: Background::Color(Color::TRANSPARENT),
                border: Border::default(),
                icon: Color::TRANSPARENT,
                placeholder: Color::TRANSPARENT,
                value: Color::TRANSPARENT,
                selection: Color::TRANSPARENT,
            }),
        value,
        length,
        groups: vec![length],
        invalid: false,
        disabled: false,
    }
}

impl<'a, Message: Clone + 'a> InputOtp<'a, Message> {
    /// Splits the slots into groups separated by a dash, e.g. `&[3, 3]`.
    pub fn groups(mut self, groups: &[usize]) -> Self {
        self.groups = groups.to_vec();
        self
    }

    /// Sets the message produced when all the slots are filled and
    /// <kbd>Enter</kbd> is pressed.
    pub fn on_submit(mut self, message: Message) -> Self {
        self.input = self.input.on_submit(message);
        self
    }

    /// Marks the input as invalid.
    pub fn invalid(mut self, invalid: bool) -> Self {
        self.invalid = invalid;
        self
    }

    /// Disables the input (`has-disabled:opacity-50`).
    pub fn disabled(mut self, disabled: bool) -> Self {
        if disabled {
            self.input = self.input.on_input_maybe(None::<fn(String) -> Message>);
        }

        self.disabled = disabled;
        self
    }

    /// Sets the [`Id`] of the underlying text input.
    pub fn id(mut self, id: impl Into<iced::widget::Id>) -> Self {
        self.input = self.input.id(id);
        self
    }

    fn slots(&self) -> Vec<Rectangle> {
        let mut slots = Vec::with_capacity(self.length);
        let mut x = 0.0;
        let mut remaining = self.length;

        for (index, &group) in self.groups.iter().enumerate() {
            let group = group.min(remaining);

            for _ in 0..group {
                slots.push(Rectangle::new(Point::new(x, 0.0), Size::new(SLOT, SLOT)));
                x += SLOT - 1.0;
            }

            remaining -= group;
            x += 1.0;

            if index + 1 < self.groups.len() && remaining > 0 {
                x += SEPARATOR;
            }
        }

        while slots.len() < self.length {
            slots.push(Rectangle::new(Point::new(x, 0.0), Size::new(SLOT, SLOT)));
            x += SLOT - 1.0;
        }

        slots
    }

    fn width(&self) -> f32 {
        self.slots().last().map_or(0.0, |slot| slot.x + slot.width)
    }
}

#[derive(Debug)]
struct State {
    focused: bool,
    since: Instant,
}

struct FocusQuery(bool);

impl Operation for FocusQuery {
    fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<()>)) {
        operate(self);
    }

    fn focusable(
        &mut self,
        _id: Option<&Id>,
        _bounds: Rectangle,
        state: &mut dyn operation::Focusable,
    ) {
        self.0 |= state.is_focused();
    }
}

impl<'a, Message: Clone + 'a> Widget<Message, Theme, iced::Renderer> for InputOtp<'a, Message> {
    fn tag(&self) -> tree::Tag {
        tree::Tag::of::<State>()
    }

    fn state(&self) -> tree::State {
        tree::State::new(State {
            focused: false,
            since: Instant::now(),
        })
    }

    fn children(&self) -> Vec<Tree> {
        vec![Tree::new(
            &self.input as &dyn Widget<Message, Theme, iced::Renderer>,
        )]
    }

    fn diff(&self, tree: &mut Tree) {
        tree.diff_children(&[&self.input as &dyn Widget<Message, Theme, iced::Renderer>]);
    }

    fn size(&self) -> Size<Length> {
        Size::new(Length::Shrink, Length::Shrink)
    }

    fn layout(
        &mut self,
        tree: &mut Tree,
        renderer: &iced::Renderer,
        _limits: &layout::Limits,
    ) -> layout::Node {
        let size = Size::new(self.width(), SLOT);
        let input = Widget::<Message, Theme, iced::Renderer>::layout(
            &mut self.input,
            &mut tree.children[0],
            renderer,
            &layout::Limits::new(size, size),
        );

        layout::Node::with_children(size, vec![input])
    }

    fn operate(
        &mut self,
        tree: &mut Tree,
        layout: Layout<'_>,
        renderer: &iced::Renderer,
        operation: &mut dyn Operation,
    ) {
        Widget::<Message, Theme, iced::Renderer>::operate(
            &mut self.input,
            &mut tree.children[0],
            layout.children().next().unwrap(),
            renderer,
            operation,
        );
    }

    fn update(
        &mut self,
        tree: &mut Tree,
        event: &Event,
        layout: Layout<'_>,
        cursor: mouse::Cursor,
        renderer: &iced::Renderer,
        clipboard: &mut dyn Clipboard,
        shell: &mut Shell<'_, Message>,
        viewport: &Rectangle,
    ) {
        let input_layout = layout.children().next().unwrap();

        Widget::<Message, Theme, iced::Renderer>::update(
            &mut self.input,
            &mut tree.children[0],
            event,
            input_layout,
            cursor,
            renderer,
            clipboard,
            shell,
            viewport,
        );

        let mut query = FocusQuery(false);
        Widget::<Message, Theme, iced::Renderer>::operate(
            &mut self.input,
            &mut tree.children[0],
            input_layout,
            renderer,
            &mut query,
        );

        let state = tree.state.downcast_mut::<State>();

        if state.focused != query.0 {
            state.focused = query.0;
            state.since = Instant::now();
            shell.request_redraw();
        }

        if state.focused
            && let Event::Window(window::Event::RedrawRequested(now)) = event
        {
            shell.request_redraw_at(*now + std::time::Duration::from_millis(500));
        }
    }

    fn draw(
        &self,
        tree: &Tree,
        renderer: &mut iced::Renderer,
        theme: &Theme,
        _style: &renderer::Style,
        layout: Layout<'_>,
        _cursor: mouse::Cursor,
        _viewport: &Rectangle,
    ) {
        let state = tree.state.downcast_ref::<State>();
        let faded = self
            .disabled
            .then(|| shadcn_iced_core::theme::faded(theme, style::DISABLED_OPACITY))
            .flatten();
        let theme = faded.as_ref().unwrap_or(theme);
        let t = tokens(theme);
        let p = &t.palette;
        let origin = layout.bounds().position();
        let radius = t.radius_lg();
        let slots = self.slots();
        let chars: Vec<char> = self.value.chars().collect();
        let active = state
            .focused
            .then(|| chars.len().min(self.length.saturating_sub(1)));

        let mut groups = Vec::new();
        let mut index = 0;
        for &group in &self.groups {
            groups.push((index, (index + group).min(self.length)));
            index += group;
        }

        let draw_slot = |i: usize, renderer: &mut iced::Renderer| {
            let bounds = slots[i] + (origin - Point::ORIGIN);
            let (first, last) = groups
                .iter()
                .find(|(start, end)| (*start..*end).contains(&i))
                .map(|(start, end)| (i == *start, i + 1 == *end))
                .unwrap_or((true, true));

            let mut corners = border::radius(0);
            if first {
                corners = corners.top_left(radius).bottom_left(radius);
            }
            if last {
                corners = corners.top_right(radius).bottom_right(radius);
            }

            let is_active = active == Some(i);

            if is_active {
                let ring = if self.invalid {
                    style::invalid_outset_ring(&t, radius)
                } else {
                    style::ring(&t, radius)
                };

                renderer.fill_quad(
                    renderer::Quad {
                        bounds: bounds.expand(ring.width),
                        border: Border {
                            color: ring.color,
                            width: ring.width,
                            radius: border::Radius {
                                top_left: if corners.top_left > 0.0 {
                                    corners.top_left + ring.width
                                } else {
                                    0.0
                                },
                                top_right: if corners.top_right > 0.0 {
                                    corners.top_right + ring.width
                                } else {
                                    0.0
                                },
                                bottom_right: if corners.bottom_right > 0.0 {
                                    corners.bottom_right + ring.width
                                } else {
                                    0.0
                                },
                                bottom_left: if corners.bottom_left > 0.0 {
                                    corners.bottom_left + ring.width
                                } else {
                                    0.0
                                },
                            },
                        },
                        ..renderer::Quad::default()
                    },
                    Background::Color(Color::TRANSPARENT),
                );
            }

            renderer.fill_quad(
                renderer::Quad {
                    bounds,
                    border: Border {
                        color: if self.invalid {
                            p.destructive
                        } else if is_active {
                            p.ring
                        } else {
                            p.input
                        },
                        width: 1.0,
                        radius: corners,
                    },
                    ..renderer::Quad::default()
                },
                // `dark:bg-input/30`
                Background::Color(if t.is_dark() {
                    alpha(p.input, 0.3)
                } else {
                    Color::TRANSPARENT
                }),
            );

            if let Some(c) = chars.get(i) {
                renderer.fill_text(
                    advanced_text::Text {
                        content: c.to_string(),
                        bounds: bounds.size(),
                        size: Pixels(style::text::SM),
                        line_height: advanced_text::LineHeight::Absolute(Pixels(20.0)),
                        font: font::sans(),
                        align_x: advanced_text::Alignment::Center,
                        align_y: alignment::Vertical::Center,
                        shaping: advanced_text::Shaping::Basic,
                        wrapping: advanced_text::Wrapping::None,
                    },
                    bounds.center(),
                    p.foreground,
                    bounds,
                );
            } else if is_active && (state.since.elapsed().as_millis() / 500).is_multiple_of(2) {
                let center = bounds.center();

                renderer.fill_quad(
                    renderer::Quad {
                        bounds: Rectangle::new(
                            Point::new(center.x - 0.5, center.y - 8.0),
                            Size::new(1.0, 16.0),
                        ),
                        ..renderer::Quad::default()
                    },
                    Background::Color(p.foreground),
                );
            }
        };

        // `has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20` on
        // each group.
        if self.invalid {
            let ring = style::invalid_outset_ring(&t, radius);

            for &(start, end) in &groups {
                if start >= end {
                    continue;
                }

                let first = slots[start] + (origin - Point::ORIGIN);
                let last = slots[end - 1] + (origin - Point::ORIGIN);
                let bounds = Rectangle::new(
                    first.position(),
                    Size::new(last.x + last.width - first.x, first.height),
                );

                renderer.fill_quad(
                    renderer::Quad {
                        bounds: bounds.expand(ring.width),
                        border: Border {
                            color: ring.color,
                            width: ring.width,
                            radius: border::radius(radius + ring.width),
                        },
                        ..renderer::Quad::default()
                    },
                    Background::Color(Color::TRANSPARENT),
                );
            }
        }

        for i in 0..self.length {
            if active != Some(i) {
                draw_slot(i, renderer);
            }
        }

        if let Some(i) = active {
            draw_slot(i, renderer);
        }

        // Group separators (`MinusIcon`).
        for (_, end) in groups.iter().take(groups.len().saturating_sub(1)) {
            if *end == 0 || *end >= self.length {
                continue;
            }

            let previous = slots[end - 1] + (origin - Point::ORIGIN);
            let center = Point::new(
                previous.x + previous.width + SEPARATOR / 2.0,
                previous.center_y(),
            );

            renderer.fill_quad(
                renderer::Quad {
                    // Lucide's `minus` at 16px.
                    bounds: Rectangle::new(
                        Point::new(center.x - 14.0 / 3.0 - 0.67, center.y - 0.67),
                        Size::new(28.0 / 3.0 + 1.33, 1.33),
                    ),
                    border: border::rounded(0.67),
                    ..renderer::Quad::default()
                },
                Background::Color(p.foreground),
            );
        }
    }

    fn mouse_interaction(
        &self,
        _tree: &Tree,
        layout: Layout<'_>,
        cursor: mouse::Cursor,
        _viewport: &Rectangle,
        _renderer: &iced::Renderer,
    ) -> mouse::Interaction {
        if cursor.is_over(layout.bounds()) {
            mouse::Interaction::Text
        } else {
            mouse::Interaction::None
        }
    }
}

impl<'a, Message: Clone + 'a> From<InputOtp<'a, Message>> for Element<'a, Message> {
    fn from(otp: InputOtp<'a, Message>) -> Self {
        Element::new(otp)
    }
}

View it on GitHub

Examples

Separator

use crate::Message;
use shadcn_iced::prelude::*;

/// A static control that does nothing when used.
fn noop<T>(_: T) -> Message {
    Message::Noop
}

input_otp(6, "", noop).groups(&[2, 2, 2])

Disabled

use crate::Message;
use shadcn_iced::prelude::*;

/// A static control that does nothing when used.
fn noop<T>(_: T) -> Message {
    Message::Noop
}

input_otp(6, "123456", noop).groups(&[3, 3]).disabled(true)

Invalid

use crate::Message;
use shadcn_iced::prelude::*;

/// A static control that does nothing when used.
fn noop<T>(_: T) -> Message {
    Message::Noop
}

input_otp(6, "000000", noop)
    .groups(&[2, 2, 2])
    .invalid(true)

Four Digits

use crate::Message;
use shadcn_iced::prelude::*;

/// A static control that does nothing when used.
fn noop<T>(_: T) -> Message {
    Message::Noop
}

input_otp(4, "", noop)