العناصر
Slider
يختار Slider قيمة على امتداد مسار (مستوى الصوت، السطوع)، أو نطاقًا من قيمتين لكل منهما مقبض (نطاق السعر). يتولى سلوكه بنفسه: الضغط على المسار ينقل أقرب مقبض إلى موضع الضغط، ويتبع المقبض السحب سواء بالماوس أو بالإصبع؛ وتُقرَّب القيم إلى step؛ وكل مقبض نقطة توقف في تركيز لوحة المفاتيح ويتحرك بمفاتيح الأسهم، وهو عنصر قابل للضبط لقارئات الشاشة بقيمته الخاصة. أما المظهر فمن شأن التطبيق: فـ Slider لا يملك ألوانًا أو أحجامًا أو أنصاف أقطار أو حركة خاصة به. يتكوّن من مسار بداخله مؤشر، ومن النطاق والمقابض على المؤشر، ثم عناصره الفرعية (تسمية، القيمة كنص)، ويتولى التطبيق تنسيق كل جزء.
import { type JSX, mount, signal, Text, View } from "nsh";
import { Slider, type SliderValue } from "nsh/base";
import { desktop, Log, Panel, size, sliderFill, sliderLook, sliderValueClass, sliderValueStyle, Split, ui } from "./palette";
// The look is the shared layer's `sliderLook` and `sliderFill` (BJORK UI's
// slider in the palette's tones): an 8-point rail, a fill that deepens at its
// start and fades into the thumb at the thumb, a 16 × 8 pill thumb that
// brightens under the pointer and while dragged, the keyboard's focus ring
// around the rail, and 35% when disabled. The spread holds only what never
// changes; the fill follows the value as a prop of its own. The demo shows a slider with its value column, a
// bare one, a range, a disabled one and vertical bands, each named above it.
const show = (v: SliderValue) => (typeof v === "number" ? `${v}` : `${v[0]}–${v[1]}`);
const clock = (seconds: number) => `${Math.floor(seconds / 60)}:${String(Math.round(seconds) % 60).padStart(2, "0")}`;
const LENGTH = 225;
function Row(props: { label: string; value?: string; children: JSX.Element }) {
return (
<View class={desktop ? "gap-2 px-3 py-3" : "gap-2 px-4 py-4"}>
<View class="flex-row items-baseline gap-3">
<Text style={{ flex: 1, fontSize: size.label, fontWeight: "500", color: ui().text }}>{props.label}</Text>
<Text style={{ fontSize: size.label, color: ui().muted, fontVariant: ["tabular-nums"] }}>{props.value ?? ""}</Text>
</View>
{props.children}
</View>
);
}
function App() {
const strength = signal(64);
const position = signal(84);
const price = signal<[number, number]>([200, 800]);
const bands = [signal(0), signal(3), signal(-2)];
const events = signal<string[]>([]);
const log = (name: string) => ({ value: next }: { value: SliderValue }) => events.set([`${name} → ${show(next)}`, ...events()].slice(0, 5));
const line = () => <View style={{ height: 1, marginLeft: desktop ? 12 : 16, backgroundColor: ui().line }} />;
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>slider</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Drag a thumb or click the track, or Tab to a thumb and use the arrow keys" : "Drag a thumb, or tap the track"}
</Text>
<Split
gap={14}
inspector={
<>
<Panel title="value">
<Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>
strength {strength()} · position {position()} · price [{price()[0]}, {price()[1]}]
</Text>
<Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>
bands {bands.map((b) => b()).join(" / ")}
</Text>
</Panel>
<Log title="onChangeEnd({ value })" lines={events()} minHeight={92} />
</>
}
>
<View style={{ ...ui().card, borderRadius: 16, overflow: "hidden" }}>
<Row label="Option strength">
<Slider
value={strength()}
onChange={({ value }) => strength.set(value as number)}
onChangeEnd={log("strength")}
accessibilityLabel="Option strength"
rangeStyle={sliderFill(0, strength() / 100)}
{...sliderLook({ value: true })}
>
<Text class={sliderValueClass} style={sliderValueStyle()}>{`${strength()}%`}</Text>
</Slider>
</Row>
{line()}
<Row label="Playback position" value={`${clock(position())} / ${clock(LENGTH)}`}>
<Slider
value={position()}
max={LENGTH}
onChange={({ value }) => position.set(value as number)}
onChangeEnd={log("position")}
accessibilityLabel="Playback position"
rangeStyle={sliderFill(0, position() / LENGTH)}
{...sliderLook()}
/>
</Row>
{line()}
<Row label="Price" value={`$${price()[0]} – $${price()[1]}`}>
<Slider
value={price()}
min={0}
max={1000}
step={10}
onChange={({ value }) => price.set(value as [number, number])}
onChangeEnd={log("price")}
startAccessibilityLabel="Minimum price"
accessibilityLabel="Maximum price"
rangeStyle={sliderFill(price()[0] / 1000, price()[1] / 1000)}
{...sliderLook()}
/>
</Row>
{line()}
<Row label="Locked intensity">
<Slider value={72} disabled accessibilityLabel="Locked intensity" rangeStyle={sliderFill(0, 0.72)} {...sliderLook({ value: true })}>
<Text class={sliderValueClass} style={sliderValueStyle()}>72%</Text>
</Slider>
</Row>
{line()}
<Row label="Equalizer" value="dB">
<View class="flex-row justify-around pt-1">
{["Bass", "Mid", "Treble"].map((name, i) => (
<View class="items-center gap-2">
<Slider
value={bands[i]()}
min={-12}
max={12}
axis="vertical"
onChange={({ value }) => bands[i].set(value as number)}
onChangeEnd={log(name.toLowerCase())}
accessibilityLabel={name}
rangeStyle={sliderFill(0, (bands[i]() + 12) / 24, true)}
{...sliderLook({ vertical: true })}
/>
<Text style={{ fontSize: size.detail, color: ui().muted, fontVariant: ["tabular-nums"] }}>
{`${name} ${bands[i]() > 0 ? "+" : ""}${bands[i]()}`}
</Text>
</View>
))}
</View>
</Row>
</View>
</Split>
</View>
);
}
mount(App);use nsh_base::{SliderValue, slider, theme};
use nsh_core::prelude::*;
fn strength(level: Signal<SliderValue>) -> impl IntoElement {
let percent = move || format!("{}%", level.get().end());
slider()
.value(move || level.get())
.accessibility_label("Option strength")
.on_change(move |next: &SliderValue| level.set(*next))
// The value in a column after the rail.
.flex_row()
.items_center()
.gap_2()
// An 8-point rail; the thumbs travel inside it.
.track_style(|s| s.h(px(8.0)).flex_1().px(px(8.0)).rounded_full().bg(move || theme().muted))
.indicator_style(|s| s.h_full())
// The fill, from the rail's edge, fading into the thumb's tone.
.range_style(|s| {
s.ml(-px(8.0))
.rounded_full()
.bg_linear_to_r()
.gradient_from(move || theme().primary)
.gradient_to(move || theme().muted_foreground)
})
// A 16 x 8 pill sitting in the rail.
.thumb_style(|s| {
s.w(px(16.0))
.h(px(8.0))
.rounded_full()
.bg(move || theme().muted_foreground)
.hover(|s| s.brightness(1.1))
.active(|s| s.brightness(1.25))
})
.child(
text(percent)
.w(px(42.0))
.text_right()
.text_xs()
.tabular_nums()
.text_color(move || theme().muted_foreground),
)
}import { mount, signal, Text } from "nsh";
import { Slider } from "nsh/base";
function App() {
const strength = signal(64);
return (
<Slider
value={strength()}
onChange={({ value }) => strength.set(value as number)}
accessibilityLabel="Option strength"
class="flex-row items-center gap-2"
trackClass="h-2 flex-1 px-2 rounded-full bg-muted focus-within:ring-2 focus-within:ring-primary/36 focus-within:ring-offset-2 focus-within:ring-offset-background disabled:opacity-35"
indicatorClass="h-full"
rangeClass="-ml-2 rounded-full bg-linear-to-r from-primary via-primary to-muted-foreground"
thumbClass="h-2 w-4 rounded-full bg-muted-foreground transition hover:brightness-110 active:brightness-125 focus:outline-none disabled:brightness-100 motion-reduce:transition-none"
thumbStyle={{ boxShadow: "inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.42)" }}
>
<Text class="w-[42px] text-right font-mono text-[11px] text-muted-foreground">{`${strength()}%`}</Text>
</Slider>
);
}
mount(App);يأتي Slider من nsh/base، وهي حزمة nsh-base في Rust. في Rust، ينشئ slider() مثيلًا منه بدالة ضبط لكل خاصية (.value(..)، .thumb_style(..))، ودالة مستمع لكل حدث (.on_change(..))، ودوال التنسيق المطبوعة لـ Slider نفسه، و.child(..) لتسميته. قيمته من النوع SliderValue: Single(f64) أو Range(f64, f64).
#القيمة
القيمة رقم، أو [start, end] لنطاق بمقبضين؛ فشكل القيمة هو ما يحدد عدد المقابض. يملك التطبيق القيمة عبر value ويضبط القيمة التي يبلغ عنها onChange، كما في كل عنصر تحكم في nsh/base؛ أو يترك القيمة لـ Slider عبر defaultValue، ويسمع التغييرات مع ذلك. أثناء السحب يتبع المقبضان والنطاق المؤشر في الإطار نفسه، محليًا؛ وعند رفع المؤشر يظهر value الخاص بالتطبيق مجددًا، كما ضبطه التطبيق لآخر تغيير في السحب؛ وتسمع قارئات الشاشة قيم السحب تباعًا أثناء جريانه، وقيمته النهائية بخلاف ذلك.
const price = signal<[number, number]>([200, 800]);
<Slider
value={price()}
onChange={({ value }) => price.set(value as [number, number])}
onChangeEnd={({ value }) => save(value)}
min={0}
max={1000}
step={10}
accessibilityLabel="Maximum price"
startAccessibilityLabel="Minimum price"
/>;
- يبلغ
onChangeعن كل قيمة جديدة يطلبها المستخدم: عند الضغط على المسار، وعند كل خطوة من السحب، ولكل مفتاح. وكل بلاغ يحمل قيمة جديدة. - يبلغ
onChangeEndعن القيمة التي انتهى عندها التغيير، مرة واحدة: عند رفع المؤشر بعد ضغط أو سحب ضبط قيمة، وبعد كل خطوة بمفتاح أو بقارئ شاشة (وكل منها تغيير كامل). احفظ القيمة أو أرسلها هناك. - تُقرَّب القيم إلى
step، محسوبة منmin(min={1} step={2}يعطي 1، 3، 5، …)، وتبقى بينminوmax؛ وmaxقابلة للوصول دائمًا، فتكون الخطوة الأخيرة أقصر حين لا يكون النطاق عددًا صحيحًا من الخطوات. ويقبلstep={0}أي قيمة. - لا يتخطى مقبضا النطاق أحدهما الآخر: المقبض المسحوب يتوقف عند الآخر. والسحب الذي يبدأ على مقبضين عند قيمة واحدة يحرّك المقبض القادر على الذهاب في اتجاه المؤشر.
- القيمة المعروضة هي قيمة التطبيق، مقيَّدة بـ
minوmax(وبترتيب طرفي النطاق)، والقيمة الخارجة عن شبكة الخطوة تُعرض في موضعها؛ ويقرّبها تغيير المستخدم التالي.
#الخصائص
| الخاصية | Rust | النوع | الافتراضي | الوصف |
|---|---|---|---|---|
value | .value(..) | number | [number, number] | لا شيء | القيمة (متحكَّم بها): رقم، أو نطاق بمقبضين. يضبط التطبيق القيمة التي يبلغ عنها onChange. |
defaultValue | .default_value(..) | number | [number, number] | 0 | القيمة الأولية ما دام value غير مضبوط؛ ويحتفظ Slider عندئذٍ بقيمته الخاصة. |
min | .min(..) | number | 0 | أصغر قيمة. |
max | .max(..) | number | 100 | أكبر قيمة؛ والقيمة الأقل من min تصبح min. |
step | .step(..) | number | 1 | الخطوة التي تُقرَّب إليها القيم، محسوبة من min؛ ويقبل 0 أي قيمة. |
scale | .scale(..) | "linear" | "logarithmic" | "linear" | كيفية توزع القيم على المسار: بالتساوي، أو بحسب رتب المقدار (من 1 إلى 1000، ثلث المسافة هو 10). يتطلب اللوغاريتمي أن يكون min أكبر من 0؛ ومع min أقل يكون خطيًا. |
axis | .axis(..) | "horizontal" | "vertical" | "horizontal" | اتجاه المسار: min عند البداية (اليسار، واليمين في التخطيط من اليمين إلى اليسار)، أو عند الأسفل. يُقرأ عند إنشاء Slider. |
disabled | .disabled(..) | boolean | false | لا تغييرات من المؤشر أو لوحة المفاتيح أو قارئ الشاشة؛ وتُطبَّق فئات disabled: على Slider وأجزائه. |
accessibilityLabel | .accessibility_label(..) | string | لا شيء | ما تنطق به قارئات الشاشة للمقبض (وفي النطاق، لمقبض النهاية). |
startAccessibilityLabel | .start_accessibility_label(..) | string | accessibilityLabel | ما تنطق به قارئات الشاشة لمقبض بداية النطاق. |
trackClass، trackStyle | .track_style(|s| ..) | string، StyleProp<ViewStyle> | لا شيء | المسار: المنطقة التي تستقبل الضغطات والسحب. |
indicatorClass، indicatorStyle | .indicator_style(|s| ..) | string، StyleProp<ViewStyle> | لا شيء | المؤشر، السكة التي تُربط بها القيم. |
rangeClass، rangeStyle | .range_style(|s| ..) | string، StyleProp<ViewStyle> | لا شيء | النطاق، من min إلى المقبض (وبين مقبضي النطاق)، ويضعه Slider. |
thumbClass، thumbStyle | .thumb_style(|s| ..) | string، StyleProp<ViewStyle> | لا شيء | كل مقبض، متمركز على قيمته. |
children | .child(..) | JSX.Element | لا شيء | المحتوى بعد المسار: تسمية، القيمة كنص. |
class، style | دوال التنسيق | string، StyleProp<ViewStyle> | لا شيء | Slider نفسه: العمود (أو الصف) المحيط بالمسار والتسمية. |
#الأحداث
| الحدث | Rust | الحمولة | متى |
|---|---|---|---|
onChange | .on_change(|value: &SliderValue| ..) | { value: number | [number, number] } | طلب المستخدم قيمة جديدة: ضغطة على المسار، أو خطوة من السحب، أو مفتاح، أو زيادة أو إنقاص من قارئ الشاشة. |
onChangeEnd | .on_change_end(|value: &SliderValue| ..) | { value: number | [number, number] } | انتهى التغيير: رُفع المؤشر، أو اكتملت خطوة بمفتاح أو بقارئ شاشة. مرة واحدة لكل تغيير. |
#الأجزاء
| الجزء | بداخل | يُنسَّق بـ |
|---|---|---|
| Slider | class، style | |
| المسار | Slider، أولًا | trackClass، trackStyle |
| المؤشر | المسار | indicatorClass، indicatorStyle |
| النطاق | المؤشر، يوضع بحسب القيم | rangeClass، rangeStyle |
| مقبض (اثنان في النطاق) | المؤشر، متمركز على قيمته | thumbClass، thumbStyle |
| التسمية | Slider، بعد المسار | عناصرها الخاصة (children) |
يستقبل المسار الضغطات والسحب. طول المؤشر هو نطاق القيم، من min عند حافته الأمامية (عند أسفله في الوضع العمودي)؛ ويضع Slider النطاق والمقابض عليه، فيتمركز كل مقبض على قيمته مهما كان حجمه. طريقتان لتخطيطها:
- المسار هو السكة (المثال أعلاه):
h-2 px-2 rounded-full bg-mutedمع مؤشرh-full. تُبقي الحشوة المقابض داخل السكة عند الطرفين، ويبدأ-ml-2الخاص بالنطاق التعبئة من حافة السكة نفسها. - المسار شريط حول السكة، أطول ليسهل العثور عليه بالإصبع على شاشة اللمس:
h-8 justify-centerمع مؤشرh-2 rounded-full(وw-8 items-centerوw-2 h-fullفي Slider العمودي).
الحالات التي تعرضها الأجزاء:
- المقبض:
hover:تحت المؤشر، وactive:أثناء سحبه، وfocus:مع تركيز لوحة المفاتيح، وdisabled:. - المسار:
active:أثناء سحب ناتج عن ضغطة عليه؛ وهوgroup، فيأخذ المؤشر والنطاق والمقابضgroup-hover:وgroup-active:منه. - كل جزء:
disabled:ما دام Slider كذلك.
تتحرك المقابض مع تغير القيمة، وتتبع السحب فورًا. أما حركة حالات المقابض فاختيار التطبيق (transition لمقبض يزداد سطوعه عند hover: وactive:)، ويُبقيها motion-reduce: ساكنة لمن يطلبون حركة أقل. وتركيز لوحة المفاتيح للمقبض: يحيط focus-within: على المسار بالسكة كلها بينما يملك مقبض التركيز، ويحل focus:outline-none الخاص بالمقبض محل مخطط التركيز الأصلي للمنصة.
#لوحة المفاتيح
على macOS والويب يكون كل مقبض نقطة توقف في تركيز لوحة المفاتيح (Tab)، ويستجيب المقبض المركَّز لمفاتيح slider في WAI-ARIA:
| المفتاح | الوظيفة |
|---|---|
| السهم الأيمن، السهم الأعلى | خطوة واحدة للأعلى (وفي Slider العمودي أيضًا). |
| السهم الأيسر، السهم الأسفل | خطوة واحدة للأسفل. |
| Page Up، Page Down | عُشر النطاق للأعلى أو للأسفل (على امتداد المقياس)، وخطوة واحدة على الأقل. |
| Home، End | إلى أقصى ما يبلغه المقبض للأسفل أو للأعلى (من بداية النطاق حتى نهايته). |
في التخطيط من اليمين إلى اليسار يمتد Slider الأفقي من اليمين، وmin الخاص به عند الطرف الأيمن: السهم الأيسر خطوة للأعلى والسهم الأيمن خطوة للأسفل، والسحب نحو اليسار يرفع القيمة. ويبقى السهم الأيمن والسهم الأعلى خطوة للأعلى في Slider العمودي.
كل مفتاح يغيّر القيمة يبلغ عن onChange ثم onChangeEnd. وتتكرر الأسهم والصفحات ما دامت مضغوطة. ولا تصل المفاتيح إلى Slider إلا حين لا يكون هناك إدخال نصي قيد التحرير.
#إمكانية الوصول
- كل مقبض عنصر قابل للضبط (
"adjustable"، وهو slider في المتصفح) مع تسميته وقيمته والقيم التي يمكن أن يتخذها: بداية النطاق منminحتى النهاية، ونهايته من البداية حتىmax، فتقرأ قارئات الشاشة القيمتين وتضبطهما. وتُنطق القيمة رقمًا بخانات الخطوة العشرية (2.5). - تحرك إجراءا الزيادة والإنقاص في قارئ الشاشة (تمريرة VoiceOver للأعلى وللأسفل) المقبض بخطوة، كما تفعل مفاتيح الأسهم.
- المقابض هي عناصر Slider لقارئات الشاشة، وتُسمّى بـ
accessibilityLabel(وبداية النطاق بـstartAccessibilityLabel)؛ وتُقرأ التسمية المرئية بين عناصره الفرعية في موضعها. - ما دام
disabled، تقول قارئات الشاشة إن المقابض معتمة، ولا تقبل أي إجراءات.
راجع إمكانية الوصول للاطلاع على التسميات والأدوار والإجراءات عمومًا.
#ملاحظات المنصات
- تعرض كل منصة ما ينسّقه التطبيق. وتُتابَع الضغطات والسحب في النواة، للماوس أو القلم أو الإصبع، ولا تأخذ طريقة عرض التمرير المحيطة بـ Slider سحبًا بدأ عليه.
- ينقل Tab تركيز لوحة المفاتيح بين المقابض على macOS والويب، حيث يستجيب المقبض المركَّز لمفاتيح Slider.
- قارئات الشاشة: يضبط VoiceOver (iOS، macOS) وTalkBack كل مقبض؛ وتحصل UI Automation (Windows) وإمكانية الوصول في GTK (Linux) والمتصفح (
role="slider"،aria-valuenow،aria-valuetext) على نطاقه وقيمته.