العناصر
OtpInput
يستقبل مُدخل الرمز أحادي الاستخدام عددًا ثابتًا من الأرقام (رمز تحقق من رسالة نصية، أو رمز من تطبيق مصادقة) ويعرضها في خلايا. وهو يتولى السلوك بنفسه: حقل نصي أصلي واحد، شفاف وفوق الخلايا، يستقبل لوحة المفاتيح، فتكون الكتابة وطرق الإدخال واللصق ولوحة الأرقام والملء التلقائي للرمز من النظام (“From Messages” في iOS، واقتراحات SMS في Android) كلها من المنصة نفسها، وتجد قارئات الشاشة حقلًا نصيًا واحدًا. تُقبل الأرقام فقط، حتى length؛ ويحذف Backspace آخر رقم، ويُعلِم onComplete باكتمال الرمز. أما المظهر فمن شأن التطبيق: OtpInput ليس له ألوان ولا أحجام ولا حدود ولا تسمية خاصة به. أجزاؤه هي الخلايا والمجموعات التي تنقسم إليها ومؤشر الكتابة، ويتولى التطبيق تنسيق كل جزء.
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))
}import { mount, signal } from "nsh";
import { OtpInput } from "nsh/base";
function App() {
const code = signal("");
const verify = (value: string) => console.log(`checking ${value}`);
return (
<OtpInput
length={6}
groups={2}
value={code()}
onChange={({ value }) => code.set(value)}
onComplete={({ value }) => verify(value)}
accessibilityLabel="Verification code, 6 digits"
class="flex-row gap-3"
groupClass="flex-row gap-2"
cellClass="w-10 h-12 items-center justify-center rounded-lg border border-input text-xl focus:border-ring data-filled:bg-muted"
caretClass="w-0.5 h-6 bg-foreground"
/>
);
}
mount(App);يأتي 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(..) | boolean | false | تعرض الخلايا المملوءة نقطة بدل رقمها، ويكون الحقل آمنًا: لا تقرأ قارئات الشاشة الرمز، ويبعده النظام عن اقتراحاته. |
disabled | .disabled(..) | boolean | false | لا تركيز ولا تحرير؛ تنطبق أنماط disabled: على المُدخل وأجزائه، وتعلن قارئات الشاشة أنه معطّل. |
groups | .groups(..) | number | 1 | عدد المجموعات التي تنقسم إليها الخلايا، بأكثر توزيع متساوٍ ممكن (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: الحقل حقل رمز (
textContentTypeoneTimeCode)، فيعرض شريط QuickType رمزًا من Messages أو Mail؛ وتظهر لوحة الأرقام. ويبدأ حقلmaskedمن جديد عند الكتابة فيه مرة أخرى بعد فقدانه التركيز، كما تفعل الحقول الآمنة في iOS. - Android: يتيح
autofillHintssmsOTPCodeللوحة المفاتيح وخدمة الملء التلقائي اقتراح رمز الرسالة النصية؛ وتظهر لوحة الأرقام. - macOS وWindows وLinux والويب: تذهب الكتابة واللصق إلى الحقل (في المتصفح، مع
inputmode="numeric"للوحة أرقام الهاتف)، ويظهر مؤشر I-beam فوق الخلايا.