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);