Skip to main content

herogpui_components/
input_otp.rs

1//! InputOTP — port of `@heroui/input-otp`.
2
3use std::{cell::Cell, rc::Rc, time::Duration};
4
5use gpui::{
6    prelude::*, px, Animation, AnimationExt, AnyElement, App, Entity, FocusHandle, Focusable,
7    IntoElement, KeyDownEvent, Pixels, RenderOnce, SharedString, Styled, Window,
8};
9use herogpui_core::{element_id, FieldVariant};
10use herogpui_theme::ActiveTheme;
11
12use crate::a11y::A11y as _;
13
14/// Editable state for an OTP field: one char per cell.
15/// Which characters an OTP cell accepts (`pattern`).
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
17pub enum OtpPattern {
18    /// `0-9` — the v3 default.
19    #[default]
20    Digits,
21    /// `0-9A-Za-z`
22    Alphanumeric,
23    /// Any printable character.
24    Any,
25}
26
27impl OtpPattern {
28    /// Every pattern, in display order.
29    pub const ALL: [OtpPattern; 3] = [
30        OtpPattern::Digits,
31        OtpPattern::Alphanumeric,
32        OtpPattern::Any,
33    ];
34
35    /// Whether `ch` may be entered into a cell.
36    pub fn accepts(self, ch: char) -> bool {
37        match self {
38            OtpPattern::Digits => ch.is_ascii_digit(),
39            OtpPattern::Alphanumeric => ch.is_ascii_alphanumeric(),
40            OtpPattern::Any => !ch.is_control(),
41        }
42    }
43
44    /// The display name of this pattern.
45    pub fn label(self) -> &'static str {
46        match self {
47            OtpPattern::Digits => "Digits",
48            OtpPattern::Alphanumeric => "Alphanumeric",
49            OtpPattern::Any => "Any",
50        }
51    }
52}
53
54/// State of an OTP input: the entered characters, the cursor and focus.
55pub struct OtpState {
56    cells: Vec<char>,
57    cursor: usize,
58    pub(crate) focus_handle: FocusHandle,
59    /// Disabled native controls are omitted from FormData and native form validation.
60    /// Written by `InputOTP::render` for the registered FormField to read.
61    is_successful: bool,
62    /// Resolved component validation, read by native Form submission.
63    validity: crate::validation::Validity,
64    /// Server messages routed to this field by its `Form`'s
65    /// `validationErrors` record (`form.rs`). The form writes them on a new
66    /// record; an accepted edit in any cell clears them — v3: "displayed
67    /// immediately and cleared when user modifies the field".
68    routed_errors: Vec<SharedString>,
69    /// The delivery receipt for `routed_errors`: the record revision these
70    /// messages last came from, `0` before anything arrived. The form's
71    /// delivery consults it — a record the receipt already names is a clone
72    /// of one already delivered and re-arms nothing — and an accepted edit
73    /// clears the messages without rewinding it, so the next frame's clone
74    /// cannot resurrect what the keystroke answered.
75    routed_revision: u64,
76}
77
78impl OtpState {
79    /// Fills the cells from `code`, padding with blanks and dropping any
80    /// overflow.
81    pub fn set_code(&mut self, code: &str) {
82        let len = self.cells.len();
83        let mut chars = code.chars();
84        for i in 0..len {
85            self.cells[i] = chars.next().unwrap_or(' ');
86        }
87        self.cursor = code.chars().count().min(len.saturating_sub(1));
88    }
89
90    /// `length` = number of cells (HeroUI default 4).
91    pub fn with_length(cx: &mut App, length: usize) -> Self {
92        Self {
93            cells: vec![' '; length.max(1)],
94            cursor: 0,
95            // A field is a tab stop: the handle carries that, not the element.
96            focus_handle: cx.focus_handle().tab_stop(true),
97            is_successful: true,
98            validity: crate::validation::Validity::default(),
99            routed_errors: Vec::new(),
100            routed_revision: 0,
101        }
102    }
103
104    /// Returns the entered characters as a string, omitting empty cells.
105    pub fn code(&self) -> String {
106        self.cells.iter().filter(|c| **c != ' ').collect()
107    }
108
109    /// Returns whether every cell is filled.
110    pub fn is_complete(&self) -> bool {
111        self.cells.iter().all(|c| *c != ' ')
112    }
113
114    /// Empties every cell and resets the cursor.
115    pub fn clear(&mut self) {
116        self.cells.iter_mut().for_each(|c| *c = ' ');
117        self.cursor = 0;
118    }
119
120    pub(crate) fn is_successful(&self) -> bool {
121        self.is_successful
122    }
123
124    pub(crate) fn set_successful(&mut self, is_successful: bool) {
125        self.is_successful = is_successful;
126    }
127
128    pub(crate) fn validity(&self) -> &crate::validation::Validity {
129        &self.validity
130    }
131
132    pub(crate) fn set_validity(&mut self, validity: crate::validation::Validity) {
133        self.validity = validity;
134    }
135
136    /// The server messages the form routed to this field, as last written by
137    /// the form's `validationErrors` delivery — what this field's error slot
138    /// renders. Empty once an accepted edit suppressed them or a reset hid
139    /// them.
140    pub fn routed_errors(&self) -> &[SharedString] {
141        &self.routed_errors
142    }
143
144    /// The delivery receipt beside [`Self::routed_errors`] — the record
145    /// revision these messages last came from, `0` before any delivery.
146    pub(crate) fn routed_revision(&self) -> u64 {
147        self.routed_revision
148    }
149
150    /// Replaces the routed server messages *and* the receipt that names the
151    /// record they came from, in one update. Guarded by the caller, which
152    /// compares the receipt before writing so a render cannot notify-loop.
153    pub(crate) fn set_routed(&mut self, messages: Vec<SharedString>, revision: u64) {
154        self.routed_errors = messages;
155        self.routed_revision = revision;
156    }
157
158    /// Suppresses the routed server messages after an accepted edit. The
159    /// delivery receipt is deliberately untouched — the record that delivered
160    /// already named this field, so the next frame's clone must not resurrect
161    /// what the keystroke answered.
162    pub(crate) fn clear_routed_errors(&mut self) {
163        self.routed_errors.clear();
164    }
165}
166
167impl Focusable for OtpState {
168    fn focus_handle(&self, _cx: &App) -> FocusHandle {
169        self.focus_handle.clone()
170    }
171}
172
173type OnComplete = std::sync::Arc<dyn Fn(&str, &mut Window, &mut App) + 'static>;
174
175/// An accepted edit — a typed digit, a paste, a backspace that removed
176/// something — suppresses the routed server messages (v3: server errors are
177/// "cleared when user modifies the field"). A rejected keystroke is not an
178/// edit and clears nothing.
179fn suppress_routed_errors(state: &Entity<OtpState>, cx: &mut App) {
180    if !state.read(cx).routed_errors().is_empty() {
181        state.update(cx, |state, cx| {
182            state.clear_routed_errors();
183            cx.notify();
184        });
185    }
186}
187
188/// An accepted edit also refreshes the stored validity mirror. `render`
189/// writes that mirror, so without this it is one frame old — and a
190/// completion handler may submit the form synchronously (the one-time-code
191/// auto-submit v3's `onComplete` invites) before any frame catches up. The
192/// routed messages the edit just suppressed are left out, so the mirror
193/// says what the field says *now*: its own `isInvalid`, `validationErrors`
194/// and `validate` against the current code.
195fn refresh_stored_validity(
196    state: &Entity<OtpState>,
197    is_invalid: bool,
198    validation_errors: &[SharedString],
199    validate: Option<&crate::validation::Validator<str>>,
200    cx: &mut App,
201) {
202    let code: String = state.read(cx).code();
203    let validity = crate::validation::resolve(
204        is_invalid,
205        validation_errors,
206        validate.and_then(|f| f(code.as_str())),
207        None,
208    );
209    if state.read(cx).validity() != &validity {
210        state.update(cx, |s, _| s.set_validity(validity));
211    }
212}
213
214type Slot = std::sync::Arc<dyn Fn(usize, Option<char>) -> AnyElement + 'static>;
215
216/// HeroUI's `.input-otp__slot-value` enters over 250ms with the smooth curve.
217/// GPUI's 0.3.3 public Div API does not expose a transform builder, so the
218/// portable part of that endpoint is animated here through opacity. The slot
219/// owner remains stable while the listener-free value child owns the animation,
220/// so typing a new character cannot lose the field's input path.
221const SLOT_VALUE_IN_MS: u64 = 250;
222
223#[derive(Clone)]
224struct SlotValueMotion {
225    value: Option<char>,
226    generation: usize,
227    from: f32,
228    opacity: Rc<Cell<f32>>,
229}
230
231struct SlotValueMotionFrame {
232    base: gpui::ElementId,
233    generation: usize,
234    from: f32,
235    to: f32,
236    opacity: Rc<Cell<f32>>,
237    animate: bool,
238}
239
240impl SlotValueMotionFrame {
241    fn render(self, value: gpui::Div) -> AnyElement {
242        if !self.animate {
243            self.opacity.set(self.to);
244            return value.opacity(self.to).into_any_element();
245        }
246
247        let opacity = self.opacity;
248        let from = self.from;
249        let to = self.to;
250        value
251            .with_animation(
252                element_id::indexed(&self.base, "value-in", self.generation),
253                Animation::new(Duration::from_millis(SLOT_VALUE_IN_MS))
254                    .with_easing(|t| crate::anim::Curve::Smooth.at(t)),
255                move |value, delta| {
256                    let next = from + (to - from) * delta;
257                    opacity.set(next);
258                    value.opacity(next)
259                },
260            )
261            .into_any_element()
262    }
263}
264
265fn slot_value_motion(
266    id: &gpui::ElementId,
267    value: Option<char>,
268    window: &mut Window,
269    cx: &mut App,
270) -> SlotValueMotionFrame {
271    let state = window.use_keyed_state(element_id::scoped(id, "value-motion"), cx, |_, _| {
272        let opacity = if value.is_some() { 1.0 } else { 0.0 };
273        SlotValueMotion {
274            value,
275            generation: 0,
276            from: opacity,
277            opacity: Rc::new(Cell::new(opacity)),
278        }
279    });
280    let mut current = state.read(cx).clone();
281    let target = if value.is_some() { 1.0 } else { 0.0 };
282    if current.value != value {
283        current.value = value;
284        current.generation = current.generation.wrapping_add(1);
285        current.from = current.opacity.get();
286        state.update(cx, |stored, _| *stored = current.clone());
287    }
288    if ActiveTheme::reduce_motion(cx) && (current.opacity.get() - target).abs() > f32::EPSILON {
289        current.from = target;
290        current.opacity.set(target);
291        state.update(cx, |stored, _| *stored = current.clone());
292    }
293    SlotValueMotionFrame {
294        base: id.clone(),
295        generation: current.generation,
296        from: current.from,
297        to: target,
298        opacity: current.opacity,
299        animate: current.generation != 0
300            && !ActiveTheme::reduce_motion(cx)
301            && (current.from - target).abs() > f32::EPSILON,
302    }
303}
304
305/// `textAlign` — where a digit sits inside its slot.
306#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
307pub enum OtpTextAlign {
308    /// Aligns text to the left.
309    Left,
310    /// Centers text.
311    #[default]
312    Center,
313    /// Aligns text to the right.
314    Right,
315}
316
317impl OtpTextAlign {
318    /// Every alignment, in display order.
319    pub const ALL: [OtpTextAlign; 3] = [
320        OtpTextAlign::Left,
321        OtpTextAlign::Center,
322        OtpTextAlign::Right,
323    ];
324
325    /// The display name of this alignment.
326    pub fn label(self) -> &'static str {
327        match self {
328            OtpTextAlign::Left => "Left",
329            OtpTextAlign::Center => "Center",
330            OtpTextAlign::Right => "Right",
331        }
332    }
333}
334
335/// HeroUI InputOTP.
336#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
337#[derive(IntoElement)]
338pub struct InputOTP {
339    /// `children` on `InputOTP.Slot` — v3's render prop, handed the slot's
340    /// `index` and its character.
341    slot: Option<Slot>,
342    /// `name` — the name this control submits under; read back by
343    /// [`Self::form_field`].
344    name: Option<SharedString>,
345    variant: FieldVariant,
346    /// `validate` — run by the component, not the caller.
347    validate: Option<crate::validation::Validator<str>>,
348    /// `validationErrors` — messages from a server round-trip.
349    validation_errors: Vec<SharedString>,
350    /// `textAlign` — where the digit sits inside its slot.
351    text_align: OtpTextAlign,
352    /// `autoFocus` — take focus on the first render.
353    auto_focus: bool,
354    /// `pasteTransformer` — rewrites pasted text before the slots take it.
355    paste_transformer: Option<std::sync::Arc<dyn Fn(&str) -> String + 'static>>,
356    is_invalid: bool,
357    placeholder: Option<SharedString>,
358    pattern: OtpPattern,
359    on_change: Option<std::sync::Arc<dyn Fn(&str, &mut Window, &mut App) + 'static>>,
360    state: Entity<OtpState>,
361    is_disabled: bool,
362    separator: bool,
363    on_complete: Option<OnComplete>,
364    /// `value` — the controlled code, stored for the first render only.
365    value: Option<String>,
366    /// The fill a hovered slot takes, in place of `--default-hover`.
367    slot_hover_bg: Option<gpui::Hsla>,
368    /// The corner radius of each slot, in place of the owning `field_radius`
369    /// helper.
370    radius: Option<Pixels>,
371    /// The `sx` slot, refined over the root style at the end of render.
372    sx: Option<Box<gpui::StyleRefinement>>,
373}
374
375impl InputOTP {
376    /// `value` — v3's controlled-code spelling, as a pure builder.
377    ///
378    /// The bound [`OtpState`] owns the code once the field renders, so this
379    /// seeds the state on the first render only — one char per cell — winning
380    /// over nothing else here (InputOTP has no `defaultValue`); calling
381    /// `.value(..)` twice keeps the last call, like every other builder here.
382    /// A later code is an imperative update rather than a builder:
383    /// `state.update(cx, |s, _| s.set_code(..))`.
384    pub fn value(mut self, code: impl Into<String>) -> Self {
385        self.value = Some(code.into());
386        self
387    }
388
389    /// The fill a hovered slot takes, in place of `--default-hover`.
390    pub fn slot_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
391        self.slot_hover_bg = Some(color.into());
392        self
393    }
394
395    /// The corner radius of every slot, in place of the owning `field_radius`
396    /// helper. Not a v3 prop; the removed v2 `radius` prop is prohibited and
397    /// this is a per-component repository extension.
398    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
399        self.radius = Some(radius.into());
400        self
401    }
402
403    /// Creates an OTP input backed by the given state.
404    pub fn new(state: Entity<OtpState>) -> Self {
405        Self {
406            slot: None,
407            name: None,
408            variant: FieldVariant::Primary,
409            validate: None,
410            validation_errors: Vec::new(),
411            text_align: OtpTextAlign::Center,
412            auto_focus: false,
413            paste_transformer: None,
414            is_invalid: false,
415            placeholder: None,
416            pattern: OtpPattern::Digits,
417            on_change: None,
418            state,
419            is_disabled: false,
420            separator: false,
421            on_complete: None,
422            value: None,
423            slot_hover_bg: None,
424            radius: None,
425            sx: None,
426        }
427    }
428
429    /// `children` on `InputOTP.Slot` — replaces a slot's contents.
430    ///
431    /// The closure receives the slot's `index` and its character (`None` when
432    /// empty), the values v3 passes into the same render prop.
433    pub fn slot(mut self, render: impl Fn(usize, Option<char>) -> AnyElement + 'static) -> Self {
434        self.slot = Some(std::sync::Arc::new(render));
435        self
436    }
437
438    /// `name` — the name this control submits under.
439    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
440        self.name = Some(name.into());
441        self
442    }
443
444    /// The `Form` field this control submits, when it has a `name`.
445    ///
446    /// v3 discovers a field through the DOM; gpui gives a child no way to reach
447    /// its ancestor, so the control hands the pair over instead. Borrows, so the
448    /// control is still yours to place:
449    ///
450    /// ```
451    /// # use gpui::{prelude::*, Window};
452    /// # use herogpui_components::{Form, InputOTP, OtpState};
453    /// # struct Demo;
454    /// # impl Render for Demo {
455    /// #     fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
456    /// #         let form = Form::new();
457    /// #         let state = cx.new(|cx| OtpState::with_length(cx, 4));
458    /// #         let control = InputOTP::new(state).name("code");
459    /// let field = control.form_field();
460    /// form.field(field.unwrap()).child(control)
461    /// #     }
462    /// # }
463    /// # let mut tcx = gpui::TestAppContext::single();
464    /// # tcx.update(herogpui_theme::ThemeProvider::init);
465    /// # let _ = tcx.add_window_view(|_, _| Demo);
466    /// ```
467    pub fn form_field(&self) -> Option<crate::form::FormField> {
468        let name = self.name.clone()?;
469        let state = self.state.clone();
470        Some(crate::form::FormField::code(name, state).is_required(false))
471    }
472
473    /// Sets the field variant (v3 `variant`).
474    pub fn variant(mut self, variant: FieldVariant) -> Self {
475        self.variant = variant;
476        self
477    }
478
479    /// `validate` — returns the message to show, or `None` when the code is fine.
480    ///
481    /// The component runs it and surfaces the result.
482    pub fn validate(mut self, f: impl Fn(&str) -> Option<SharedString> + 'static) -> Self {
483        self.validate = Some(std::sync::Arc::new(f));
484        self
485    }
486
487    /// `validationErrors` — messages produced elsewhere, shown ahead of
488    /// whatever `validate` returns.
489    pub fn validation_errors(
490        mut self,
491        errors: impl IntoIterator<Item = impl Into<SharedString>>,
492    ) -> Self {
493        self.validation_errors = errors.into_iter().map(Into::into).collect();
494        self
495    }
496
497    /// `autoFocus` — take focus on the first render.
498    pub fn auto_focus(mut self, v: bool) -> Self {
499        self.auto_focus = v;
500        self
501    }
502
503    /// `pasteTransformer` — rewrites pasted text before it fills the slots.
504    ///
505    /// Useful for stripping separators from a code the user copied out of an
506    /// email, e.g. `|t| t.replace('-', "")`.
507    pub fn paste_transformer(mut self, f: impl Fn(&str) -> String + 'static) -> Self {
508        self.paste_transformer = Some(std::sync::Arc::new(f));
509        self
510    }
511
512    /// `textAlign` — where each digit sits inside its slot.
513    ///
514    /// v3 documents `left` as the default; a single character in a square slot
515    /// reads better centred, which is what the slots render, so `Center` is the
516    /// default here and the other two are available.
517    pub fn text_align(mut self, align: OtpTextAlign) -> Self {
518        self.text_align = align;
519        self
520    }
521
522    /// Sets the invalid state (v3 `isInvalid`).
523    pub fn is_invalid(mut self, v: bool) -> Self {
524        self.is_invalid = v;
525        self
526    }
527
528    /// `placeholder` — the text shown in an empty cell.
529    ///
530    /// v3 documents no default: `input-otp.css` gives an empty slot nothing to
531    /// draw. This port used to default it to `'-'`, which is what the docs
532    /// table prints in its *Default* column to mean "none" — so every unfilled
533    /// cell showed a dash upstream leaves blank.
534    pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
535        self.placeholder = Some(text.into());
536        self
537    }
538
539    /// `pattern` — the characters a cell accepts. Defaults to digits.
540    pub fn pattern(mut self, pattern: OtpPattern) -> Self {
541        self.pattern = pattern;
542        self
543    }
544
545    /// Fires on every cell change, not just completion (`onChange`).
546    pub fn on_change(mut self, f: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self {
547        self.on_change = Some(std::sync::Arc::new(f));
548        self
549    }
550
551    /// Sets the disabled state (v3 `isDisabled`).
552    pub fn is_disabled(mut self, v: bool) -> Self {
553        self.is_disabled = v;
554        self
555    }
556
557    /// `InputOTP.Separator` — the dash between cell groups.
558    ///
559    /// It takes no content in v3: `.input-otp__separator` is `h-[2px] w-[6px]
560    /// rounded-sm bg-separator`, a bar rather than a glyph, so this is a flag
561    /// and not the string it used to accept.
562    pub fn separator(mut self) -> Self {
563        self.separator = true;
564        self
565    }
566
567    /// Sets the handler called with the code once every cell is filled (v3 `onComplete`).
568    pub fn on_complete(mut self, f: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self {
569        self.on_complete = Some(std::sync::Arc::new(f));
570        self
571    }
572
573    /// The one slot for caller-owned low-level styling: GPUI's styling methods
574    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
575    /// applied to the field's root element after every value the variant and
576    /// the active theme chose, so they win. The root is the row of cells —
577    /// or the column that also holds the error message, when one shows.
578    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
579        crate::util::refine_sx(&mut self.sx, style);
580        self
581    }
582}
583
584impl RenderOnce for InputOTP {
585    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
586        let is_successful = !self.is_disabled;
587        if self.state.read(cx).is_successful() != is_successful {
588            self.state
589                .update(cx, |state, _| state.set_successful(is_successful));
590        }
591        // The mount-time autofocus decision runs before the tokens. A disabled
592        // field consumes the one-shot without focusing, just like a disabled
593        // native input whose `autofocus` attribute does not rerun if enabled.
594        // Every part of the field hangs off the state entity: an `InputOTP`
595        // takes no id of its own, and the entity is the identity it does have.
596        let base_id = gpui::ElementId::named_usize("otp", self.state.entity_id().as_u64() as usize);
597        // `value` seeds the code once, before anything reads it. The state
598        // owns the cells afterwards, and `OtpState::set_code` is the
599        // imperative update. `seed_once` takes `cx` mutably, so it runs
600        // before the theme tokens, like `focus_once` below.
601        if let Some(code) = self.value.clone() {
602            let state = self.state.clone();
603            crate::util::seed_once(
604                window,
605                cx,
606                element_id::scoped(&base_id, "default"),
607                move |cx| {
608                    state.update(cx, |s, cx| {
609                        s.set_code(&code);
610                        cx.notify();
611                    });
612                },
613            );
614        }
615        let focused_handle = self.state.read(cx).focus_handle.clone();
616        if self.auto_focus {
617            let done =
618                window.use_keyed_state(element_id::scoped(&base_id, "autofocus"), cx, |_, _| false);
619            if !*done.read(cx) {
620                if !self.is_disabled {
621                    window.focus(&focused_handle, cx);
622                }
623                done.update(cx, |done, _| *done = true);
624            }
625        }
626
627        // Where the row's text starts, remembered from the last frame: a
628        // `canvas` is the only element that is told its own bounds, and a
629        // click has to be measured against something. `use_keyed_state` takes
630        // `cx` mutably, so it runs before the theme tokens — the same reason
631        // `focus_once` above runs before them.
632        let row_origin =
633            window.use_keyed_state(element_id::scoped(&base_id, "origin"), cx, |_, _| {
634                None::<Pixels>
635            });
636
637        // `.input-otp__slot` is `h-10 w-9.5` with `text-sm`, and the row and
638        // group are both `gap-2`.
639        let (cell_w, cell_h, text, slot_gap) = (px(38.), px(40.), px(14.), px(8.));
640
641        let focused = focused_handle.is_focused(window);
642        let (cells_snapshot, cursor) = {
643            let st = self.state.read(cx);
644            (st.cells.clone(), st.cursor)
645        };
646        let _length = cells_snapshot.len();
647        let disabled = self.is_disabled;
648        // Every slot in the row below paints the same corner, so it resolves
649        // once here.
650        let radius = self.radius.unwrap_or_else(|| crate::util::field_radius(cx));
651
652        // v3 order: the controlled flag, then server errors, then `validate`.
653        // The server slot carries the messages the `Form`'s
654        // `validationErrors` record routed into this state by name, ahead of
655        // this field's own `validationErrors` prop.
656        let code_now = self.state.read(cx).code();
657        let mut server_errors = self.state.read(cx).routed_errors().to_vec();
658        server_errors.extend(self.validation_errors.iter().cloned());
659        let validity = crate::validation::resolve(
660            self.is_invalid,
661            &server_errors,
662            self.validate.as_ref().and_then(|f| f(code_now.as_str())),
663            None,
664        );
665        if self.state.read(cx).validity() != &validity {
666            self.state
667                .update(cx, |state, _| state.set_validity(validity.clone()));
668        }
669        let invalid = validity.is_invalid;
670        // Clone the small token records before per-slot keyed animation state
671        // borrows `cx` mutably. This keeps the render loop free to update a
672        // character's motion without holding an immutable theme borrow over it.
673        let colors = cx.colors().clone();
674        let layout = cx.layout().clone();
675
676        // v3's `InputOTP` wraps the `input-otp` package, which renders one
677        // real `<input>` behind the slots — `aria-placeholder`,
678        // `autocomplete="one-time-code"`, no role of its own, so a text box.
679        // The slots themselves are presentational divs and stay out of the
680        // tree. The row here is that input.
681        let name = crate::a11y::Name::field(None, None, &validity);
682        let mut row = gpui::div()
683            .id(base_id.clone())
684            .a11y_named(crate::a11y::Role::TextInput, &name)
685            .a11y_text(&code_now, self.placeholder.as_ref())
686            .flex()
687            .items_center()
688            .gap(slot_gap)
689            .cursor(if disabled {
690                gpui::CursorStyle::Arrow
691            } else {
692                gpui::CursorStyle::IBeam
693            })
694            // A disabled field is not a tab stop.
695            .when(!self.is_disabled, |el| el.track_focus(&focused_handle))
696            .key_context("InputOTP")
697            .on_mouse_down(gpui::MouseButton::Left, {
698                let fh = focused_handle.clone();
699                let origin = row_origin.clone();
700                let st = self.state.clone();
701                let disabled = self.is_disabled;
702                move |ev: &gpui::MouseDownEvent, window, cx| {
703                    if disabled {
704                        return;
705                    }
706                    // The click that *grants* the focus must not disturb a
707                    // caret the value placed (a seeded code parks it after
708                    // the last filled slot); only a click on an already
709                    // focused row re-homes it, which is what v3's input-otp
710                    // does when a slot is clicked.
711                    let was_focused = fh.is_focused(window);
712                    window.focus(&fh, cx);
713                    if !was_focused {
714                        return;
715                    }
716                    // The caret lands on the slot the click hit, measured
717                    // from the row's remembered left edge. The gap after the
718                    // zero-width origin item shifts each cell 8px in;
719                    // flooring the pitch-scaled position still names the
720                    // right cell.
721                    let Some(left) = *origin.read(cx) else {
722                        return;
723                    };
724                    let pitch = f32::from(cell_w + slot_gap);
725                    let x = f32::from(ev.position.x) - f32::from(left);
726                    let len = st.read(cx).cells.len();
727                    let cell = (x / pitch).floor() as i32;
728                    st.update(cx, |s, cx| {
729                        s.cursor = cell.clamp(0, len as i32 - 1) as usize;
730                        cx.notify();
731                    });
732                }
733            });
734
735        if disabled {
736            row = row.opacity(layout.disabled_opacity);
737        }
738
739        // A zero-width item at the row's head: its bounds give the row's left
740        // edge, which the click handler above measures against (it took its
741        // own clone). A row starts with this, then its cells, so cell *i*
742        // spans `i*46 + 8` px in.
743        row = row.child(
744            gpui::canvas(
745                move |bounds, _window, cx| {
746                    let left = bounds.origin.x;
747                    if *row_origin.read(cx) != Some(left) {
748                        row_origin.update(cx, |v, _| *v = Some(left));
749                    }
750                },
751                |_, _, _, _| {},
752            )
753            .w(px(0.))
754            .h(px(0.))
755            .flex_shrink_0(),
756        );
757
758        for (i, cell_ch) in cells_snapshot.iter().enumerate() {
759            // group separator every 3 cells
760            if i > 0 && i % 3 == 0 && self.separator {
761                row = row.child(
762                    gpui::div()
763                        .flex_shrink_0()
764                        .w(px(6.))
765                        .h(px(2.))
766                        .rounded(crate::util::hairline_radius(cx))
767                        .bg(colors.separator),
768                );
769            }
770
771            let ch = *cell_ch;
772            let is_cursor_cell = focused && i == cursor && !disabled;
773            let filled = ch != ' ';
774            // The slot carries its own id because its hover listener needs
775            // element state: gpui wires hover listeners only for elements the
776            // tree can name, and the chrome ramp below tracks hover through
777            // one. The id derives from the row's, so instances never share a
778            // timeline.
779            let slot_id = element_id::indexed(&base_id, "slot", i);
780
781            let mut cell = gpui::div()
782                .id(slot_id.clone())
783                .flex()
784                .items_center()
785                .relative()
786                // `textAlign` positions the digit inside its slot.
787                .map(|c| match self.text_align {
788                    OtpTextAlign::Left => c.justify_start().pl(px(6.)),
789                    OtpTextAlign::Center => c.justify_center(),
790                    OtpTextAlign::Right => c.justify_end().pr(px(6.)),
791                })
792                .w(cell_w)
793                .h(cell_h)
794                .rounded(radius)
795                .text_size(text)
796                .line_height(px(20.))
797                .font_weight(gpui::FontWeight::SEMIBOLD);
798
799            // Every slot is filled and shadowed, empty or not. The pinned CSS
800            // gives the slot the theme field border width/color, then changes
801            // its background by variant and active/filled state — and
802            // transitions all three chrome properties rather than swapping
803            // them, so the endpoints resolve here and the shared chrome ramp
804            // (`.input-otp__slot`, lines 28-32: `background-color 150ms
805            // var(--ease-smooth), border-color 150ms var(--ease-smooth),
806            // box-shadow 150ms var(--ease-out)`) interpolates between them.
807            let slot_bg = match self.variant {
808                FieldVariant::Primary => colors.field.background,
809                FieldVariant::Secondary => colors.default.color,
810            };
811            let active_bg = match self.variant {
812                FieldVariant::Primary => colors.field.focus(),
813                FieldVariant::Secondary => colors.default.color,
814            };
815            // HeroUI's invalid rule comes after active and filled rules:
816            // every invalid slot keeps the focus background and receives
817            // the danger outline, including the keyboard-active slot — which
818            // is why the ring below is skipped once `invalid` holds.
819            let focus_ring = (!invalid && is_cursor_cell && crate::util::focus_visible(cx))
820                .then(|| crate::anim::focus_ring_endpoint(cx));
821            let idle = crate::anim::FieldChrome {
822                bg: if invalid {
823                    colors.field.focus()
824                } else if is_cursor_cell || filled {
825                    active_bg
826                } else {
827                    slot_bg
828                },
829                border: if invalid {
830                    colors.danger.color
831                } else {
832                    colors.field.border
833                },
834                border_width: if invalid {
835                    layout.border_width.max(px(1.))
836                } else {
837                    layout.field_border_width
838                },
839                ring: focus_ring,
840            };
841            // `--input-otp-slot-bg-hover` is `--default-hover` for the
842            // secondary variant; primary slots use the field hover token.
843            // Active and filled slots keep their focus background while their
844            // hover border still follows the shared field token.
845            let hover_bg = self.slot_hover_bg.unwrap_or(match self.variant {
846                FieldVariant::Primary => colors.field.hover(),
847                FieldVariant::Secondary => colors.default.hover(),
848            });
849            let hovered_bg = if is_cursor_cell || filled {
850                idle.bg
851            } else {
852                hover_bg
853            };
854            let hovered = (!self.is_disabled).then(|| crate::anim::FieldChrome {
855                bg: hovered_bg,
856                border: colors.field.border_hover(),
857                border_width: idle.border_width,
858                ring: focus_ring,
859            });
860            // The settled slot chrome comes from the endpoints themselves, and
861            // the ring rides `status-focused-field` through the shared painter
862            // on top of the slot's constant field shadow — the same paint the
863            // other field parts take.
864            let base_shadows = match self.variant {
865                FieldVariant::Primary => layout.field_shadow.clone(),
866                FieldVariant::Secondary => Vec::new(),
867            };
868            cell = cell
869                .bg(idle.bg)
870                .border(idle.border_width)
871                .border_color(idle.border);
872            // The instant ring the first frame casts is an overlay child,
873            // concentric with the slot's own `radius`; the flush geometry takes
874            // no offset gap, so the band sits straight on the slot edge.
875            cell = crate::util::with_focus_ring_overlay(
876                cell,
877                focus_ring.is_some(),
878                false,
879                radius,
880                base_shadows.clone(),
881                cx,
882            );
883            // The ramp owns the slot's border and ring past its first flip,
884            // so the instant ones it painted above stop being cast there.
885            // The cell is not `overflow-hidden`, so the flag the ramp returns
886            // (whether it is painting the state ring) has no reader here.
887            (cell, _) = crate::anim::field_chrome_ramp(
888                cell,
889                &slot_id,
890                idle,
891                hovered,
892                base_shadows,
893                radius,
894                false,
895                window,
896                cx,
897            );
898            cell = cell.text_color(colors.foreground);
899
900            // `slot` is v3's render prop on `InputOTP.Slot`: it receives the
901            // slot's `index` and its character, so a caller can draw the cell's
902            // contents without re-deriving either.
903            if let Some(render) = &self.slot {
904                cell = cell.child(render(i, if ch == ' ' { None } else { Some(ch) }));
905            } else if ch != ' ' {
906                // `.input-otp__slot-value` is `text-lg leading-6`: the digit is
907                // a step larger than the slot's own `text-sm`. The shared
908                // motion frame covers the portable opacity part of
909                // `slot-value-in`; GPUI has no public scale/translate builder.
910                let value_motion = slot_value_motion(
911                    &element_id::indexed(&base_id, "slot-value", i),
912                    Some(ch),
913                    window,
914                    cx,
915                );
916                cell = cell.child(
917                    value_motion.render(
918                        gpui::div()
919                            .text_size(px(18.))
920                            .line_height(px(24.))
921                            .child(ch.to_string()),
922                    ),
923                );
924            } else if is_cursor_cell {
925                // v3's `@keyframes caret-blink`.
926                cell = cell.child(crate::anim::caret_blink(
927                    // `.input-otp__caret` is `h-4 w-[2px] rounded-sm
928                    // bg-field-placeholder`.
929                    gpui::div()
930                        .absolute()
931                        .left(match self.text_align {
932                            OtpTextAlign::Left => px(6.),
933                            OtpTextAlign::Center => px(18.),
934                            OtpTextAlign::Right => px(30.),
935                        })
936                        .top(px(12.))
937                        .w(px(2.))
938                        .h(px(16.))
939                        .rounded(crate::util::hairline_radius(cx))
940                        .bg(colors.field.placeholder),
941                    element_id::indexed(&base_id, "caret", i),
942                    cx,
943                ));
944            } else if let Some(placeholder) = &self.placeholder {
945                // `placeholder` fills the empty, unfocused cells.
946                cell = cell.text_color(colors.muted).child(placeholder.clone());
947            }
948
949            row = row.child(cell);
950        }
951
952        // editing
953        let state_entity = self.state.clone();
954        let on_complete = self.on_complete.clone();
955        let on_change = self.on_change.clone();
956        let pattern = self.pattern;
957        let paste_transformer = self.paste_transformer.clone();
958        // The sources an accepted edit re-resolves the stored validity from
959        // (see `refresh_stored_validity`): the field's own, never the
960        // routed slot the edit suppresses.
961        let edit_is_invalid = self.is_invalid;
962        let edit_validation_errors = self.validation_errors.clone();
963        let edit_validate = self.validate.clone();
964        row = row.on_key_down(move |ev: &KeyDownEvent, window, cx| {
965            if disabled {
966                return;
967            }
968            let key: &str = &ev.keystroke.key;
969
970            // Ctrl/Cmd+V fills the slots from the clipboard. `Cmd` matters on
971            // macOS; checking only `control` would make paste dead there.
972            let paste_chord =
973                (ev.keystroke.modifiers.control || ev.keystroke.modifiers.platform) && key == "v";
974            if paste_chord {
975                if let Some(text) = cx.read_from_clipboard().and_then(|c| c.text()) {
976                    let text = match &paste_transformer {
977                        Some(f) => f(&text),
978                        None => text,
979                    };
980                    let was_complete = state_entity.read(cx).is_complete();
981                    let accepted = state_entity.update(cx, |s, cx| {
982                        let mut accepted = false;
983                        // A paste replaces the code from the cursor onward.
984                        // Every pasted char goes through the same `pattern`
985                        // gate a keystroke does (the typing branch calls
986                        // `pattern.accepts`); a digits field used to take
987                        // letters simply because they were alphanumeric.
988                        for ch in text.chars() {
989                            if s.cursor >= s.cells.len() {
990                                break;
991                            }
992                            if !pattern.accepts(ch) {
993                                continue;
994                            }
995                            accepted = true;
996                            s.cells[s.cursor] = ch.to_ascii_uppercase();
997                            s.cursor += 1;
998                        }
999                        // Leave the cursor on the last filled slot so the next
1000                        // keystroke overwrites rather than falling off the end.
1001                        if s.cursor >= s.cells.len() {
1002                            s.cursor = s.cells.len() - 1;
1003                        }
1004                        cx.notify();
1005                        accepted
1006                    });
1007                    let code: String = state_entity.read(cx).code();
1008                    if accepted {
1009                        suppress_routed_errors(&state_entity, cx);
1010                        refresh_stored_validity(
1011                            &state_entity,
1012                            edit_is_invalid,
1013                            &edit_validation_errors,
1014                            edit_validate.as_ref(),
1015                            cx,
1016                        );
1017                        if let Some(cb) = &on_change {
1018                            cb(&code, window, cx);
1019                        }
1020                    }
1021                    if !was_complete && state_entity.read(cx).is_complete() {
1022                        if let Some(cb) = &on_complete {
1023                            cb(&code, window, cx);
1024                        }
1025                    }
1026                }
1027                return;
1028            }
1029
1030            match key {
1031                "backspace" => {
1032                    let changed = state_entity.update(cx, |s, cx| {
1033                        let changed = if s.cells[s.cursor] != ' ' {
1034                            s.cells[s.cursor] = ' ';
1035                            true
1036                        } else if s.cursor > 0 {
1037                            s.cursor -= 1;
1038                            s.cells[s.cursor] = ' ';
1039                            true
1040                        } else {
1041                            false
1042                        };
1043                        if changed {
1044                            cx.notify();
1045                        }
1046                        changed
1047                    });
1048                    // A backspace that clears nothing is not a change:
1049                    // `onChange` reports what changed, never what it was told.
1050                    if changed {
1051                        suppress_routed_errors(&state_entity, cx);
1052                        refresh_stored_validity(
1053                            &state_entity,
1054                            edit_is_invalid,
1055                            &edit_validation_errors,
1056                            edit_validate.as_ref(),
1057                            cx,
1058                        );
1059                        if let Some(cb) = &on_change {
1060                            let code = state_entity.read(cx).code();
1061                            cb(&code, window, cx);
1062                        }
1063                    }
1064                }
1065                "left" => state_entity.update(cx, |s, cx| {
1066                    s.cursor = s.cursor.saturating_sub(1);
1067                    cx.notify();
1068                }),
1069                "right" => state_entity.update(cx, |s, cx| {
1070                    if s.cursor + 1 < s.cells.len() {
1071                        s.cursor += 1;
1072                    }
1073                    cx.notify();
1074                }),
1075                single if single.chars().count() == 1 && !single.is_empty() => {
1076                    // `key` is the key cap; `key_char` is what was typed, which
1077                    // is what a capital or a shifted symbol needs.
1078                    let typed = ev.keystroke.key_char.as_deref().unwrap_or(single);
1079                    let mut chars = typed.chars();
1080                    let (Some(c), None) = (chars.next(), chars.next()) else {
1081                        return;
1082                    };
1083                    let accepted = pattern.accepts(c);
1084                    if accepted {
1085                        let completed = state_entity.update(cx, |s, cx| {
1086                            s.cells[s.cursor] = c;
1087                            if s.cursor + 1 < s.cells.len() {
1088                                s.cursor += 1;
1089                            }
1090                            let done = s.is_complete();
1091                            cx.notify();
1092                            done
1093                        });
1094                        // The accepted mutation above *is* the user
1095                        // modification, so the routed server errors must be
1096                        // gone — and the stored validity mirror must agree —
1097                        // before any callback can observe the field: a
1098                        // completion handler that submits the form
1099                        // synchronously (the one-time-code auto-submit v3's
1100                        // `onComplete` invites) must never be blocked by the
1101                        // very error this keystroke answers.
1102                        suppress_routed_errors(&state_entity, cx);
1103                        refresh_stored_validity(
1104                            &state_entity,
1105                            edit_is_invalid,
1106                            &edit_validation_errors,
1107                            edit_validate.as_ref(),
1108                            cx,
1109                        );
1110                        let code = state_entity.read(cx).code();
1111                        if let Some(cb) = &on_change {
1112                            cb(&code, window, cx);
1113                        }
1114                        if completed {
1115                            if let Some(cb) = &on_complete {
1116                                cb(&code, window, cx);
1117                            }
1118                        }
1119                    }
1120                }
1121                _ => {}
1122            }
1123        });
1124        if !self.is_disabled {
1125            row = crate::util::record_focus_bounds(row, &focused_handle, window, cx);
1126        }
1127
1128        // A field that can be invalid has to be able to say why — every
1129        // message, space-joined in upstream order (React Aria's `FieldError`
1130        // default), not just the first.
1131        let error = (!validity.messages.is_empty()).then(|| validity.joined().into());
1132        let error_panel = crate::anim::field_error_panel(&base_id, error, window, cx);
1133        if let Some(error_panel) = error_panel {
1134            let el = gpui::div()
1135                .flex()
1136                .flex_col()
1137                .gap(px(6.))
1138                .child(row)
1139                .child(error_panel);
1140            crate::util::apply_sx(el, &self.sx).into_any_element()
1141        } else {
1142            crate::util::apply_sx(row, &self.sx).into_any_element()
1143        }
1144    }
1145}
1146
1147#[cfg(test)]
1148mod tests {
1149    use super::*;
1150
1151    #[test]
1152    fn digits_pattern_is_the_default() {
1153        assert_eq!(OtpPattern::default(), OtpPattern::Digits);
1154    }
1155
1156    #[test]
1157    fn digits_rejects_letters_and_symbols() {
1158        assert!(OtpPattern::Digits.accepts('7'));
1159        assert!(!OtpPattern::Digits.accepts('a'));
1160        assert!(!OtpPattern::Digits.accepts('-'));
1161    }
1162
1163    #[test]
1164    fn alphanumeric_accepts_both_cases() {
1165        assert!(OtpPattern::Alphanumeric.accepts('7'));
1166        assert!(OtpPattern::Alphanumeric.accepts('a'));
1167        assert!(OtpPattern::Alphanumeric.accepts('Z'));
1168        assert!(!OtpPattern::Alphanumeric.accepts('-'));
1169    }
1170
1171    #[test]
1172    fn any_accepts_printables_but_not_controls() {
1173        assert!(OtpPattern::Any.accepts('-'));
1174        assert!(OtpPattern::Any.accepts(' '));
1175        assert!(!OtpPattern::Any.accepts('\n'));
1176        assert!(!OtpPattern::Any.accepts('\t'));
1177    }
1178
1179    #[test]
1180    fn slot_visual_contract_keeps_pinned_state_precedence() {
1181        let source = include_str!("input_otp.rs")
1182            .split("#[cfg(test)]")
1183            .next()
1184            .expect("the implementation section is always present");
1185        assert!(source.contains("const SLOT_VALUE_IN_MS: u64 = 250"));
1186        // The chrome endpoints resolve in pinned precedence — invalid over
1187        // active/filled over resting — and the shared ramp interpolates them:
1188        // `.input-otp__slot` transitions `background-color 150ms
1189        // var(--ease-smooth), border-color 150ms var(--ease-smooth),
1190        // box-shadow 150ms var(--ease-out)`, snapped by
1191        // `motion-reduce:transition-none`.
1192        assert!(source.contains("crate::anim::field_chrome_ramp("));
1193        assert!(source.contains("colors.field.focus()"));
1194        assert!(source.contains("colors.danger.color"));
1195        assert!(source.contains("layout.border_width.max(px(1.))"));
1196        assert!(source.contains("layout.field_border_width"));
1197        assert!(source.contains("colors.field.border_hover()"));
1198        assert!(source.contains("crate::anim::focus_ring_endpoint(cx)"));
1199        assert!(source.contains(".absolute()"));
1200        assert!(source.contains(".top(px(12.))"));
1201        assert!(source.contains("value-in"));
1202    }
1203}
1204
1205crate::util::impl_component_styled!(InputOTP);