تُرجمت هذه الصفحة آليًا وقد تحتوي على أخطاء. اقرأ الأصل الإنجليزي

العناصر

OtpInput

يستقبل مُدخل الرمز أحادي الاستخدام عددًا ثابتًا من الأرقام (رمز تحقق من رسالة نصية، أو رمز من تطبيق مصادقة) ويعرضها في خلايا. وهو يتولى السلوك بنفسه: حقل نصي أصلي واحد، شفاف وفوق الخلايا، يستقبل لوحة المفاتيح، فتكون الكتابة وطرق الإدخال واللصق ولوحة الأرقام والملء التلقائي للرمز من النظام (“From Messages” في iOS، واقتراحات SMS في Android) كلها من المنصة نفسها، وتجد قارئات الشاشة حقلًا نصيًا واحدًا. تُقبل الأرقام فقط، حتى length؛ ويحذف Backspace آخر رقم، ويُعلِم onComplete باكتمال الرمز. أما المظهر فمن شأن التطبيق: OtpInput ليس له ألوان ولا أحجام ولا حدود ولا تسمية خاصة به. أجزاؤه هي الخلايا والمجموعات التي تنقسم إليها ومؤشر الكتابة، ويتولى التطبيق تنسيق كل جزء.

el-otp-input.tsx
el-otp-input.tsxLive
import { Clipboard, cleanup, mount, signal, Text, View } from "nsh";
import { Button, OtpInput, Switch } from "nsh/base";
import { button, buttonText, desktop, focusRing, hoverFill, Log, Panel, size, Split, ui } from "./palette";

// The parts: geometry and motion in classes, colors from the palette. The
// cell that takes the next digit gets the keyboard's focus ring (`focus:`,
// the palette's `info` ring) while the code has the focus, and the caret
// blinks in it (steady with reduced motion: the input's own). A cell that
// holds a digit, a bullet too, lifts out of its well: a card's fill under a
// firmer edge (`data-filled:`), faded in (`transition-colors`). Cells are
// 44 points on a touch screen, 36 with a pointer; a pressed or hovered
// button and the switches' rows take the palette's states.
const CODE = "246810";
const cell = desktop ? "w-9 h-11 text-lg" : "w-11 h-13 text-xl";
const cellClass = `${cell} items-center justify-center rounded-lg border border-line bg-inset font-semibold text-foreground transition-colors motion-reduce:transition-none data-filled:border-input data-filled:bg-card focus:outline-2 focus:outline-info focus:-outline-offset-2 disabled:opacity-40`;
const row = `-mx-2 flex-row-reverse items-center justify-between gap-3 rounded-lg px-2 ${desktop ? "py-1" : "py-2"} active:opacity-70`;
const track = `${desktop ? "w-9" : "w-12"} rounded-full p-0.5 transition-colors motion-reduce:transition-none`;
const thumb = desktop ? "size-4 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-4" : "size-5.5 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-5";

function Option(props: { label: string; checked: boolean; onChange: (next: boolean) => void }) {
  return (
    <Switch
      checked={props.checked}
      onChange={({ checked }) => props.onChange(checked)}
      class={row}
      hoverStyle={hoverFill()}
      focusStyle={focusRing()}
      trackClass={track}
      trackStyle={props.checked ? ui().accentFill : ui().inset}
      thumbClass={thumb}
      thumbStyle={props.checked ? { backgroundColor: ui().onAccent } : ui().raised}
    >
      <Text style={{ flex: 1, fontSize: size.label, color: ui().text }}>{props.label}</Text>
    </Switch>
  );
}

function Action(props: { label: string; disabled?: boolean; onPress: () => void }) {
  return (
    <Button onPress={props.onPress} disabled={props.disabled} {...button("secondary", { style: desktop ? {} : { flex: 1 } })}>
      <Text style={buttonText()}>{props.label}</Text>
    </Button>
  );
}

function App() {
  const code = signal("");
  const masked = signal(false);
  const disabled = signal(false);
  const status = signal<"typing" | "checking" | "right" | "wrong">("typing");
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 6));
  // Checking the code takes a moment, as a server's answer does.
  let check: ReturnType<typeof setTimeout> | undefined;
  cleanup(() => clearTimeout(check));
  const verify = (value: string) => {
    status.set("checking");
    clearTimeout(check);
    check = setTimeout(() => status.set(value === CODE ? "right" : "wrong"), 600);
  };
  const message = () =>
    ({
      typing: { text: `${code().length} of 6 digits`, color: ui().muted },
      checking: { text: "Checking…", color: ui().muted },
      right: { text: "Verified", color: ui().up },
      wrong: { text: "That code is not right", color: ui().down },
    })[status()];

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>otp input</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Click the code and type it, or paste it; Tab reaches it too" : "Tap the code to type it, or paste it"}
      </Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="value · masked · disabled">
              <Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>
                {code() ? `value “${masked() ? "•".repeat(code().length) : code()}”` : "value “” (empty)"}
              </Text>
              <Option
                label="Masked"
                checked={masked()}
                onChange={(next) => {
                  masked.set(next);
                  log(next ? "masked: bullets, a secure field" : "masked off");
                }}
              />
              <Option
                label="Disabled"
                checked={disabled()}
                onChange={(next) => {
                  disabled.set(next);
                  log(next ? "disabled: no focus" : "enabled");
                }}
              />
            </Panel>
            <Log title="onChange · onComplete · onFocus · onBlur" lines={events()} minHeight={120} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 20 : 16, gap: 16, alignItems: "center" }}>
          <View style={{ gap: 4, alignItems: "center" }}>
            <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>Enter the code</Text>
            <Text style={{ fontSize: size.detail, color: ui().muted }}>{`We sent it to •••• 4821 (it is ${CODE})`}</Text>
          </View>
          <OtpInput
            length={6}
            groups={2}
            value={code()}
            masked={masked()}
            disabled={disabled()}
            onChange={({ value: next }) => {
              code.set(next);
              status.set("typing");
              log(`onChange “${next}”`);
            }}
            onComplete={({ value }) => {
              log(`onComplete “${value}”`);
              verify(value);
            }}
            onFocus={() => log("onFocus")}
            onBlur={() => log("onBlur")}
            accessibilityLabel="Verification code, 6 digits"
            class="flex-row items-center gap-3"
            groupClass="flex-row gap-1.5"
            cellClass={cellClass}
            cellStyle={{ boxShadow: ui().inset.boxShadow }}
            caretClass="w-0.5 h-6 rounded-full"
            caretStyle={{ backgroundColor: ui().text }}
          />
          <Text style={{ fontSize: size.detail, color: message().color }}>{message().text}</Text>
          <View class={desktop ? "flex-row gap-2" : "flex-row gap-2 self-stretch"}>
            <Action
              label="Copy the code"
              onPress={() => {
                Clipboard.writeText(CODE);
                log("copied: paste it into the code");
              }}
            />
            <Action
              label="Clear"
              disabled={code() === ""}
              onPress={() => {
                code.set("");
                status.set("typing");
                log("value set to “”");
              }}
            />
          </View>
        </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{OtpInputCompleteEvent, otp_input, theme};
use nsh_core::prelude::*;

fn verification(verify: impl Fn(&str) + 'static) -> impl IntoElement {
    let code = signal(SharedString::default());
    otp_input()
        .length(6)
        .groups(2)
        .value(move || Some(code.get()))
        .on_change(move |next: &SharedString| code.set(next.clone()))
        .on_complete(move |e: &OtpInputCompleteEvent| verify(&e.value))
        .accessibility_label("Verification code, 6 digits")
        .flex_row()
        .gap_3()
        .group_style(|s| s.flex_row().gap_2())
        .cell_style(|s| {
            s.w(px(40.0))
                .h(px(48.0))
                .items_center()
                .justify_center()
                .rounded(px(8.0))
                .border_1()
                .border_color(move || theme().input)
                .text_xl()
        })
        // The cell that takes the next digit, while the code has the focus,
        // and the cells that hold a digit.
        .cell_class("focus:border-ring data-filled:bg-muted")
        .caret_style(|s| s.w(px(2.0)).h(px(24.0)).bg(move || theme().foreground))
}

يأتي OtpInput من nsh/base، وهي حزمة (crate) nsh-base في Rust. في Rust، ينشئ otp_input() مُدخلًا مع دالة ضبط لكل خاصية (.length(..)، .cell_style(..))، ودالة مستمع لكل حدث (.on_complete(..))، ودوال التنسيق المُنمَّطة للمُدخل نفسه.

#الرمز

  • متحكَّم به: مرّر value واضبطه في onChange. تعرض الخلايا value الذي يمرره التطبيق كما هو؛ والقيمة التي يضبطها التطبيق (مثل مسح الرمز بعد إدخال رمز خاطئ) لا يُبلَّغ عنها أبدًا.
  • غير متحكَّم به: مرّر defaultValue (أو لا شيء)، واقرأ الرمز من onChange وonComplete.

ما يفعله المستخدم يُقرأ من الحقل، ومؤشر الكتابة دائمًا في نهايته:

المستخدمالرمز
يكتب رقمًايُضاف في النهاية ما دام الرمز أقصر من length؛ وعند اكتماله لا يتغير شيء.
يكتب رقمًا بعرض كامل (0–9، من طريقة إدخال لغات شرق آسيا)يُضاف الرقم بصيغة ASCII.
يكتب أي شيء آخرلا يتغير شيء؛ يتجاهله الحقل.
يضغط Backspaceيُحذف آخر رقم.
يلصق، أو يملأ النظام الرمز تلقائيًاتُضاف أرقامه بالطريقة نفسها؛ والنص الذي فيه length من الأرقام أو أكثر (123-456، مثل رمز رسالة) يستبدل الرمز بأول length من أرقامه.

يُبلِّغ onChange عن كل تغيير مع الرمز الجديد، ويتبعه onComplete حين يملأ التغيير الخلية الأخيرة، فيتحقق التطبيق من الرمز عندها.

#الخصائص

الخاصيةRustالنوعالافتراضيالوصف
length.length(..)numberمطلوبةعدد أرقام الرمز: خلية لكل رقم.
value.value(..)stringلا شيءالرمز، يتحكم به التطبيق (يُعرض كما هو). وبدونها يحتفظ المُدخل برمزه الخاص.
defaultValue.default_value(..)stringلا شيءالرمز في البداية، ما دام value غير مضبوط.
masked.masked(..)booleanfalseتعرض الخلايا المملوءة نقطة بدل رقمها، ويكون الحقل آمنًا: لا تقرأ قارئات الشاشة الرمز، ويبعده النظام عن اقتراحاته.
disabled.disabled(..)booleanfalseلا تركيز ولا تحرير؛ تنطبق أنماط disabled: على المُدخل وأجزائه، وتعلن قارئات الشاشة أنه معطّل.
groups.groups(..)number1عدد المجموعات التي تنقسم إليها الخلايا، بأكثر توزيع متساوٍ ممكن (3 + 3 لستة أرقام في مجموعتين)؛ مجموعة واحدة على الأقل، وبحد أقصى مجموعة لكل خلية.
accessibilityLabel.accessibility_label(..)stringلا شيءما تقوله قارئات الشاشة عن الحقل: الغرض من الرمز وعدد أرقامه.
cellClass، cellStyle.cell_class(..)، .cell_style(|s| ..)string، StyleProp<TextStyle>لا شيءكل خلية: حجمها وحدّها ونصها (color، fontSize، التي يرثها رقمها)؛ ويُنسِّق focus: وdata-filled: حالاتها (انظر الأجزاء).
groupClass، groupStyle.group_class(..)، .group_style(|s| ..)string، StyleProp<ViewStyle>لا شيءكل مجموعة من الخلايا (flex-row gap-2).
caretClass، caretStyle.caret_class(..)، .caret_style(|s| ..)string، StyleProp<ViewStyle>لا شيءمؤشر الكتابة: عرضه وارتفاعه ولونه.
class، styleدوال التنسيقstring، StyleProp<ViewStyle>لا شيءالمُدخل نفسه: الصف (أو العمود) الذي يحيط بمجموعاته.

#الأحداث

الحدثRustالحمولةمتى
onChange.on_change(|value: &SharedString| ..)(e: { value: string })غيّر المستخدم الرمز: كتابةً أو حذفًا أو لصقًا أو ملءًا تلقائيًا.
onComplete.on_complete(|e: &OtpInputCompleteEvent| ..){ value }ملأ تغيير المستخدم الخلية الأخيرة، بعد onChange الخاص به.
onFocus / onBlur.on_focus / .on_blurلا شيءحصل المُدخل على تركيز لوحة المفاتيح / فقده.

#دوال Ref

الدالةRustالوصف
focus()focus() (OtpInputApi)تأخذ تركيز لوحة المفاتيح وتعرض لوحة المفاتيح البرمجية، كما يفعل الضغط على المُدخل.

#الأجزاء

الجزءداخليُنسَّق بـ
المُدخلclass، style
مجموعةالمُدخل، واحدة لكل مجموعةgroupClass، groupStyle؛ disabled: يتبع المُدخل
خليةمجموعتها، واحدة لكل رقمcellClass، cellStyle؛ focus: على الخلية التي تستقبل الرقم التالي ما دام المُدخل يملك التركيز، data-filled: على كل خلية فيها رقم، disabled: يتبع المُدخل
الرقمخلية مملوءةأنماط نص الخلية (text-xl، font-semibold)؛ • عندما masked
مؤشر الكتابةالخلية الفارغة التي تستقبل الرقم التالي، ما دام المُدخل يملك التركيزcaretClass، caretStyle

الخلية التي تستقبل الرقم التالي هي التي تلي آخر رقم، أو الخلية الأخيرة عند اكتمال الرمز. ويومض مؤشر الكتابة كما يومض في الحقل النصي (نصف ثانية ظاهر ونصف ثانية مخفي، ويثبت لحظة بعد كل تعديل)، عبر شفافيته، بشكل أصلي، مع بقائه في مكانه في التخطيط؛ ومع تقليل الحركة يبقى ثابتًا. والضغط في أي موضع على المُدخل يمنحه التركيز: فالحقل يقع فوق الخلايا.

الخلية التي فيها رقم، مُقنَّعًا كان أو لا، لها السمة data-filled، فتُنسِّق الأصناف data-filled: الخلايا التي ملأها المستخدم (حدّ أقوى، تعبئة: data-filled:border-foreground data-filled:bg-muted)، كما يكتب shadcn/ui حالاته. ومع group في cellClass، تُنسِّق الأصناف group-data-filled: ما داخل الخلية المملوءة، أي رقمها. تغيّر الخلايا أصنافها ولا تغيّر محتواها أبدًا، فيُعاد تنسيق خلية واحدة عند كل ضغطة مفتاح، ولا يُعاد رسم أي شيء.

#إمكانية الوصول

  • تجد قارئات الشاشة حقلًا نصيًا واحدًا، اسمه من accessibilityLabel (اذكر الغرض من الرمز وطوله: “رمز التحقق، 6 أرقام”) وقيمته هي الرمز؛ أما الخلايا فمخفية عنها. وما دام masked فالحقل آمن، فلا يُقرأ الرمز بصوت عالٍ.
  • لوحة المفاتيح هي لوحة المنصة: لوحة الأرقام على الهواتف، وأي لوحة مفاتيح على أجهزة سطح المكتب (تُقبل الأرقام فقط)، ومفتاح Backspace. ويصل Tab إلى المُدخل كما يصل إلى أي حقل نصي.
  • ما دام disabled فلا يأخذ تركيزًا، وتعلن قارئات الشاشة أنه معطّل.

انظر إمكانية الوصول للاطلاع على التسميات والأدوار والإعلانات بوجه عام.

#ملاحظات المنصات

  • iOS: الحقل حقل رمز (textContentType oneTimeCode)، فيعرض شريط QuickType رمزًا من Messages أو Mail؛ وتظهر لوحة الأرقام. ويبدأ حقل masked من جديد عند الكتابة فيه مرة أخرى بعد فقدانه التركيز، كما تفعل الحقول الآمنة في iOS.
  • Android: يتيح autofillHints smsOTPCode للوحة المفاتيح وخدمة الملء التلقائي اقتراح رمز الرسالة النصية؛ وتظهر لوحة الأرقام.
  • macOS وWindows وLinux والويب: تذهب الكتابة واللصق إلى الحقل (في المتصفح، مع inputmode="numeric" للوحة أرقام الهاتف)، ويظهر مؤشر I-beam فوق الخلايا.