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

العناصر

Slider

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

el-slider.tsx
el-slider.tsxLive
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),
        )
}

يأتي 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(..)number0أصغر قيمة.
max.max(..)number100أكبر قيمة؛ والقيمة الأقل من min تصبح min.
step.step(..)number1الخطوة التي تُقرَّب إليها القيم، محسوبة من min؛ ويقبل 0 أي قيمة.
scale.scale(..)"linear" | "logarithmic""linear"كيفية توزع القيم على المسار: بالتساوي، أو بحسب رتب المقدار (من 1 إلى 1000، ثلث المسافة هو 10). يتطلب اللوغاريتمي أن يكون min أكبر من 0؛ ومع min أقل يكون خطيًا.
axis.axis(..)"horizontal" | "vertical""horizontal"اتجاه المسار: min عند البداية (اليسار، واليمين في التخطيط من اليمين إلى اليسار)، أو عند الأسفل. يُقرأ عند إنشاء Slider.
disabled.disabled(..)booleanfalseلا تغييرات من المؤشر أو لوحة المفاتيح أو قارئ الشاشة؛ وتُطبَّق فئات disabled: على Slider وأجزائه.
accessibilityLabel.accessibility_label(..)stringلا شيءما تنطق به قارئات الشاشة للمقبض (وفي النطاق، لمقبض النهاية).
startAccessibilityLabel.start_accessibility_label(..)stringaccessibilityLabelما تنطق به قارئات الشاشة لمقبض بداية النطاق.
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] }انتهى التغيير: رُفع المؤشر، أو اكتملت خطوة بمفتاح أو بقارئ شاشة. مرة واحدة لكل تغيير.

#الأجزاء

الجزءبداخليُنسَّق بـ
Sliderclass، 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) على نطاقه وقيمته.