العناصر
NumberInput
يحتفظ مُدخل الأرقام برقم يكتبه المستخدم أو يغيّره بالخطوات: كمية أو سعر أو حجم. وهو يملك سلوكه بنفسه: يقبل حقله رقمًا، أو النص الذي في طريقه إلى أن يصبح رقمًا (-، 1.)، ويرفض كل ما عدا ذلك، بما في ذلك أرقام وإشارات لوحات المفاتيح الأخرى؛ وأزراره، ومفتاحا Up وDown، وإجراءا الزيادة والإنقاص في قارئ الشاشة، تغيّر الرقم بمقدار step مع الحفاظ على خاناته العشرية، وتتوقف عند min وmax؛ ومغادرة الحقل تقيّد الرقم المكتوب بهما. أما المظهر فللتطبيق: NumberInput لا يملك ألوانًا أو أحجامًا أو حدودًا أو أيقونات أو نصوصًا خاصة به. ويتكوّن من زر الإنقاص، والحقل بين prefix وsuffix الخاصين بالتطبيق، وزر الزيادة (أو الحقل بينهما وعمود يضم الزرين، controlsRight)، وينسّق التطبيق كل جزء ويملأ الأزرار.
import { type JSX, mount, signal, Text, View } from "nsh";
import { NumberInput, Switch } from "nsh/base";
import { desktop, focusRing, hoverFill, Log, Panel, size, Split, ui } from "./palette";
// The parts: geometry and states in classes, colors from the theme's
// tokens. The input is one recessed well (a stronger border under the
// pointer, the focus ring while its field edits: `focus-within:`); the
// buttons light up under the pointer and while pressed (a transition the
// reduced-motion setting stills) and fade where a step would do nothing (at
// `min` / `max`) or while the input is disabled, without their hover. A
// pointer gets the compact sizes. The price stacks its buttons on the right
// with its currency beside the field.
const frame = [
desktop ? "h-8 rounded-lg" : "h-11 rounded-xl",
"flex-row items-stretch overflow-hidden border border-input bg-field",
"enabled:hover:border-faint focus-within:outline-2 focus-within:outline-info disabled:opacity-50",
].join(" ");
const stepButton = [
desktop ? "w-8" : "w-11",
"items-center justify-center transition-colors duration-100 motion-reduce:transition-none",
"enabled:hover:bg-accent active:bg-track disabled:opacity-35",
].join(" ");
const stackedButton = "flex-1 items-center justify-center transition-colors duration-100 motion-reduce:transition-none enabled:hover:bg-accent active:bg-track disabled:opacity-35";
const field = () => ({
flex: 1,
minWidth: 0,
paddingHorizontal: desktop ? 8 : 10,
fontSize: desktop ? 14 : 16,
color: ui().text,
placeholderColor: ui().faint,
fontVariant: ["tabular-nums" as const],
});
/** A minus or a plus drawn with two bars in the text color. */
function Glyph(props: { plus?: boolean; small?: boolean }) {
const length = props.small ? 8 : desktop ? 10 : 12;
const bar = () => ({ position: "absolute" as const, borderRadius: 1, backgroundColor: ui().text });
return (
<View style={{ pointerEvents: "none", width: length, height: length }}>
<View style={{ ...bar(), left: 0, right: 0, top: length / 2 - 0.75, height: 1.5 }} />
{props.plus ? <View style={{ ...bar(), top: 0, bottom: 0, left: length / 2 - 0.75, width: 1.5 }} /> : null}
</View>
);
}
function Row(props: { label: string; detail: string; children: JSX.Element }) {
return (
<View class={`flex-row items-center gap-3 ${desktop ? "px-3 py-2.5" : "px-4 py-3"}`}>
<View style={{ flex: 1, gap: 2 }}>
<Text style={{ fontSize: size.label, fontWeight: "500", color: ui().text }}>{props.label}</Text>
<Text style={{ fontSize: size.detail, color: ui().muted }}>{props.detail}</Text>
</View>
<View style={{ width: desktop ? 148 : 168 }}>{props.children}</View>
</View>
);
}
const show = (v: number | null) => (v === null ? "null" : `${v}`);
function App() {
const tickets = signal<number | null>(2);
const price = signal<number | null>(19.95);
const temperature = signal<number | null>(null);
const disabled = signal(false);
const events = signal<string[]>([]);
const change = (name: string, value: { set: (v: number | null) => void }) => ({ value: next }: { value: number | null }) => {
value.set(next);
events.set([`${name} → ${show(next)}`, ...events()].slice(0, 5));
};
const line = () => <View style={{ height: 1, marginLeft: desktop ? 12 : 16, backgroundColor: ui().line }} />;
const sides = () => ({
disabled: disabled(),
class: frame,
inputStyle: { ...field(), textAlign: "center" as const },
decrementButtonClass: `${stepButton} border-r border-line`,
incrementButtonClass: `${stepButton} border-l border-line`,
decrementButton: <Glyph />,
incrementButton: <Glyph plus />,
});
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>number-input</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Type a number, click − and +, or press ↑ ↓ (Page Up / Down: ten steps) in the field" : "Type a number, or tap − and +"}
</Text>
<Split
gap={14}
inspector={
<>
<Panel title="value · disabled">
<Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>
tickets {show(tickets())} · price {show(price())} · °C {show(temperature())}
{disabled() ? " (disabled)" : ""}
</Text>
<Switch
checked={disabled()}
onChange={({ checked }) => disabled.set(checked)}
class={`-mx-2 flex-row-reverse items-center justify-between gap-3 rounded-lg px-2 ${desktop ? "py-1" : "py-2"} active:opacity-70`}
hoverStyle={hoverFill()}
focusStyle={focusRing()}
trackClass={`${desktop ? "w-9" : "w-12"} rounded-full p-0.5 transition-colors motion-reduce:transition-none`}
trackStyle={disabled() ? ui().accentFill : ui().inset}
thumbClass={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"}
thumbStyle={disabled() ? { backgroundColor: ui().onAccent } : ui().raised}
>
<Text style={{ flex: 1, fontSize: size.label, color: ui().text }}>Disabled</Text>
</Switch>
</Panel>
<Log title="onChange({ value })" lines={events()} minHeight={92} />
</>
}
>
<View style={{ ...ui().card, borderRadius: 16, overflow: "hidden" }}>
<Row label="Tickets" detail="1 to 10">
<NumberInput
value={tickets()}
onChange={change("tickets", tickets)}
min={1}
max={10}
accessibilityLabel="Tickets"
{...sides()}
/>
</Row>
{line()}
<Row label="Price" detail="Steps of 0.05">
<NumberInput
value={price()}
onChange={change("price", price)}
min={0}
step={0.05}
accessibilityLabel="Price"
controlsRight
disabled={disabled()}
class={frame}
inputStyle={field()}
controlsClass={`${desktop ? "w-6" : "w-9"} border-l border-line`}
incrementButtonClass={`${stackedButton} border-b border-line`}
decrementButtonClass={stackedButton}
incrementButton={<Glyph plus small />}
decrementButton={<Glyph small />}
suffix={<Text style={{ alignSelf: "center", paddingRight: 8, fontSize: size.detail, color: ui().muted }}>USD</Text>}
/>
</Row>
{line()}
<Row label="Temperature" detail="−20 to 40 °C, halves">
<NumberInput
value={temperature()}
onChange={change("temperature", temperature)}
min={-20}
max={40}
step={0.5}
placeholder="Not set"
accessibilityLabel="Temperature"
{...sides()}
/>
</Row>
</View>
</Split>
</View>
);
}
mount(App);use nsh_base::{number_input, theme};
use nsh_core::prelude::*;
fn tickets(count: Signal<Option<f64>>) -> impl IntoElement {
number_input()
.value(move || count.get())
.min(1.0)
.max(10.0)
.accessibility_label("Tickets")
.on_change(move |next: &Option<f64>| count.set(*next))
.h(px(32.0))
.flex_row()
.rounded(px(8.0))
.border_1()
.border_color(move || theme().input)
.input_style(|s| s.flex_1().text_center().text_color(move || theme().foreground))
.decrement_button_style(|s| s.w(px(32.0)).items_center().justify_center())
.increment_button_style(|s| s.w(px(32.0)).items_center().justify_center())
.decrement_button(text("−"))
.increment_button(text("+"))
}import { mount, signal, Text } from "nsh";
import { NumberInput } from "nsh/base";
function App() {
const tickets = signal<number | null>(2);
return (
<NumberInput
value={tickets()}
onChange={({ value }) => tickets.set(value)}
min={1}
max={10}
accessibilityLabel="Tickets"
class="h-8 flex-row overflow-hidden rounded-lg border border-input bg-field focus-within:outline-2 focus-within:outline-ring"
inputClass="flex-1 text-center text-foreground"
decrementButtonClass="w-8 items-center justify-center enabled:hover:bg-accent active:bg-muted disabled:opacity-40"
incrementButtonClass="w-8 items-center justify-center enabled:hover:bg-accent active:bg-muted disabled:opacity-40"
decrementButton={<Text>−</Text>}
incrementButton={<Text>+</Text>}
/>
);
}
mount(App);يأتي NumberInput من nsh/base، وهي حزمة (crate) nsh-base في Rust. في Rust، ينشئ number_input() واحدًا منه بدالة ضبط (setter) لكل خاصية (.value(..)، .input_style(..))، ودالة مستمع لكل حدث (.on_change(..))، ودوال التنسيق المطبّعة للمُدخل نفسه، و.prefix(..) / .suffix(..) لما يحيط بالحقل. وقيمته من النوع Option<f64>.
#القيمة
القيمة رقم، أو null للحقل الفارغ. يملكها التطبيق عبر value ويضبط القيمة التي يبلغ عنها onChange، كما في كل عنصر تحكم في nsh/base؛ أو يترك القيمة للمُدخل عبر defaultValue، ويسمع التغييرات مع ذلك.
يُفصل نص الحقل عن القيمة حيثما لزم ذلك، ليتمكن المستخدم من الكتابة بحرية:
- يبلغ
onChangeعن الرقم الذي يُقرأ من النص كلما تغيّر هذا الرقم:nullعندما لا يُقرأ من النص أي رقم (فارغ،-،.)؛ وكتابة1.بعد1، أو1.50بعد1.5، لا تُبلغ عن شيء. - يبقى النص كما كتبه المستخدم ما دام يُقرأ على أنه القيمة التي يحتفظ بها المُدخل، لذا فإن مُدخلًا متحكَّمًا به يعيد كل قيمة مُبلَّغ عنها يحتفظ بـ
1.و2.50. أما القيمة التي يضبطها التطبيق ولم يبلّغ عنها المُدخل فتظهر في الحقل بأقصر نص يُقرأ منه (0.3،12). - أثناء الكتابة قد يكون الرقم خارج
minوmax(الرقم 1 في طريقه إلى 15 معmin={10})، ويُبلَّغ عنه كما كُتب. وعند مغادرة الحقل، أو عند الضغط على Return / Enter، يُقيَّد الرقم بالنطاق ويُبلَّغ بذلك؛ أما النص الذي لا يُقرأ منه أي رقم فيبقى كما هو. - تطلب الخطوة (زر أو مفتاح أو إجراء من قارئ الشاشة) القيمة التالية عبر
onChange؛ ومع قيمة متحكَّم بها يتحرك الحقل عندما يعيدها التطبيق، محتفظًا بخانات الخطوة العشرية (99.5صعودًا إلىmax={100}يعرض100.0)، فالتطبيق الذي يرفض خطوة يُبقي الحقل حيث هو. والتعديل الذي يرفضه التطبيق يبقى في الحقل ما دام قيد التحرير، ويزول عند مغادرة الحقل.
const price = signal<number | null>(19.95);
<NumberInput
value={price()}
onChange={({ value }) => price.set(value)}
onSubmit={({ value }) => save(value)}
min={0}
step={0.05}
placeholder="Price"
accessibilityLabel="Price"
/>;
يقبل الحقل:
- أرقامًا، ونقطة عشرية واحدة، وإشارة في البداية (
+أو-)؛ ويُرفض الحرف، أو النقطة أو الإشارة الثانية، أو الإشارة في موضع آخر، ويعود الحقل إلى نصه. - الأرقام والإشارات والنقطة بالعرض الكامل، وعلامة الطرح (U+2212)، والنقطة الأيديوغرافية (
。)، وتُقرأ بصيغها في ASCII، كما تكتبها طرق الإدخال الصينية واليابانية. - فاصلة بوصفها نقطة عشرية ما دام النص خاليًا من النقطة (
3,25تعني 3.25)، كما يكتبها مفتاح الكسر العشري في لوحات مفاتيح لغات كثيرة.
#الخاصيات
| الخاصية | Rust | النوع | الافتراضي | الوصف |
|---|---|---|---|---|
value | .value(..) | number | null | لا شيء | القيمة (متحكَّم بها): رقم، أو null للحقل الفارغ. يضبط التطبيق القيمة التي يبلغ عنها onChange. |
defaultValue | .default_value(..) | number | null | لا شيء | القيمة الأولية ما دام value غير مضبوط؛ فيحتفظ المُدخل حينئذٍ بقيمته الخاصة. |
min | .min(..) | number | لا شيء | أصغر قيمة: تتوقف الخطوات عندها، والقيمة المكتوبة الأقل منها تُقيَّد بها عند مغادرة الحقل. |
max | .max(..) | number | لا شيء | أكبر قيمة، مثلما يعمل min من الأعلى؛ وما كان أقل من min فهو min. |
step | .step(..) | number | 1 | مقدار الخطوة (Page Up / Page Down: عشر خطوات)؛ وما ليس رقمًا موجبًا فهو 1. |
disabled | .disabled(..) | boolean | false | لا كتابة ولا خطوات ولا تركيز؛ وتنطبق فئات disabled: على المُدخل وأجزائه. |
readonly | .readonly(..) | boolean | false | يمكن تحديد النص ونسخه لكن لا يمكن تغييره، لا بالكتابة ولا بالخطوات؛ والأزرار معطّلة. |
placeholder | .placeholder(..) | string | لا شيء | يظهر ما دام الحقل فارغًا. |
accessibilityLabel | .accessibility_label(..) | string | لا شيء | ما تنطق به قارئات الشاشة للحقل، عندما لا توجد تسمية مرئية تسمّيه. |
controlsRight | .controls_right(..) | boolean | false | الزران معًا في عمود على يمين الحقل، وزر الزيادة فوق زر الإنقاص. يُقرأ عند إنشاء المُدخل. |
inputClass، inputStyle | .input_style(|s| ..) | string، StyleProp<InputStyle> | لا شيء | الحقل: صندوقه ونصه (color، fontSize، textAlign، placeholderColor، caretColor). |
decrementButtonClass، decrementButtonStyle | .decrement_button_style(|s| ..) | string، StyleProp<ViewStyle> | لا شيء | زر الإنقاص. |
incrementButtonClass، incrementButtonStyle | .increment_button_style(|s| ..) | string، StyleProp<ViewStyle> | لا شيء | زر الزيادة. |
decrementButton، incrementButton | .decrement_button(..)، .increment_button(..) | JSX.Element | لا شيء | محتوى الأزرار: علامة أو أيقونة. |
controlsClass، controlsStyle | .controls_style(|s| ..) | string، StyleProp<ViewStyle> | لا شيء | العمود الذي يضم الزرين مع controlsRight. |
prefix | .prefix(..) | JSX.Element | لا شيء | ما يأتي قبل الحقل، بعد الزر الموجود على يساره: علامة عملة أو أيقونة. |
suffix | .suffix(..) | JSX.Element | لا شيء | ما يلي الحقل، قبل الأزرار على يمينه: وحدة أو عملة. |
class، style | دوال التنسيق | string، StyleProp<ViewStyle> | لا شيء | المُدخل نفسه: الصف المحيط بالحقل والأزرار. |
#الأحداث
| الحدث | Rust | الحمولة | متى |
|---|---|---|---|
onChange | .on_change(|value: &Option<f64>| ..) | { value: number | null } | تغيّر الرقم الذي يُقرأ من الحقل (كتابة، أو خطوة، أو زيادة أو إنقاص من قارئ الشاشة، أو مغادرة الحقل بقيمة خارج النطاق). |
onSubmit | .on_submit(|value: &Option<f64>| ..) | { value: number | null } | Return / Enter في الحقل: القيمة، مقيَّدة بـ min وmax. |
onFocus | .on_focus(..) | لا شيء | حصل الحقل على تركيز لوحة المفاتيح. |
onBlur | .on_blur(..) | لا شيء | فقد الحقل تركيز لوحة المفاتيح (بعد تقييد قيمته بـ min وmax أولًا). |
#الأجزاء
| الجزء | الموضع | التنسيق بـ |
|---|---|---|
| المُدخل | class، style | |
| زر الإنقاص | المُدخل، أولًا (مع controlsRight: عمود الأزرار، ثانيًا) | decrementButtonClass، decrementButtonStyle؛ ومحتواه decrementButton |
| الحقل | المُدخل | inputClass، inputStyle |
| البادئة | المُدخل، قبل الحقل | عناصرها الخاصة (prefix) |
| اللاحقة | المُدخل، بعد الحقل | عناصرها الخاصة (suffix) |
| زر الزيادة | المُدخل، أخيرًا (مع controlsRight: عمود الأزرار، أولًا) | incrementButtonClass، incrementButtonStyle؛ ومحتواه incrementButton |
| عمود الأزرار | المُدخل، أخيرًا، مع controlsRight | controlsClass، controlsStyle |
المُدخل صف يرتّبه التطبيق: flex-row على class، وflex-1 على الحقل ليأخذ المساحة التي تتركها الأزرار. الحالات التي تعرضها الأجزاء:
- المُدخل:
focus-within:ما دام حقله قيد التحرير (حلقة حول المُدخل كله)،disabled:. - الحقل:
focus:ما دام قيد التحرير،disabled:. - الزر:
hover:تحت المؤشر،active:أثناء الضغط،disabled:عندما يكون المُدخل معطّلًا أو للقراءة فقط، وحيث لا تفعل الخطوة شيئًا (الإنقاص عندmin، والزيادة عندmax). ضع التمرير خلفenabled:(enabled:hover:bg-accent) كي لا يضيء الزر المعطّل.
حركة الحالات من اختيار التطبيق (transition-colors)، وmotion-reduce: يُبقيها ساكنة لمن يطلبون حركة أقل.
#لوحة المفاتيح
ما دام الحقل قيد التحرير، يلتقط المُدخل مفاتيح spin button في WAI-ARIA، وهي تتكرر عند إبقائها مضغوطة:
| المفتاح | الوظيفة |
|---|---|
| Up | خطوة واحدة للأعلى. |
| Down | خطوة واحدة للأسفل. |
| Page Up, Page Down | عشر خطوات للأعلى أو للأسفل. |
يبقى Home وEnd للحقل: فهما يحركان المؤشر في النص. والأزرار ليست محطات لتركيز لوحة المفاتيح: ينتقل Tab من الحقل إلى ما يلي المُدخل، وتُبقي الضغطة على زر التركيز في الحقل (في أجهزة سطح المكتب تنقل الضغطة التركيز إلى الحقل إن كان في مكان آخر؛ وفي الشاشات اللمسية تتركه حيث هو، فلا تظهر لوحة مفاتيح).
#إمكانية الوصول
- الحقل هو spin button (
"spinbutton") مع تسميته وقيمته (الرقم الذي يُقرأ منه، بينminوmax) ونصه. - تغيّره إجراءا الزيادة والإنقاص في قارئ الشاشة (السحب لأعلى ولأسفل في VoiceOver) بخطوة، كما يفعل Up وDown.
- الأزرار مخفية عن قارئات الشاشة، كما هي في حقل الأرقام في المتصفح: فإجراءا الزيادة والإنقاص في الحقل نفسه يغيّرانه بخطوة.
- ما دام
disabled، تُعلن قارئات الشاشة أن الحقل معتَّم.
راجع Accessibility للاطلاع على التسميات والأدوار والإجراءات بوجه عام.
#ملاحظات المنصات
- الحقل هو حقل النص الخاص بالمنصة (Input): فلوحة المفاتيح والمؤشر والتحديد والنسخ واللصق وطرق الإدخال كلها من النظام. وتعرض الهواتف لوحة مفاتيح عشرية، مع إشارة عندما يكون
minأقل من 0 أو غير مضبوط. - ترسم كل منصة ما ينسّقه التطبيق. وتصل مفاتيح Up / Down وPage Up / Page Down إلى المُدخل من لوحة مفاتيح فعلية على كل منصة.