العناصر
Toggle
الـ Toggle هو زر يبقى في حالة تشغيل أو إيقاف: الخط العريض في شريط أدوات النص، أو نجمة المفضلة، أو خيار في العرض. Toggle يتولى سلوك واحد منها: الضغطات، والتفعيل بلوحة المفاتيح، وحالة التعطيل، ودور الزر مع حالة الضغط لقارئات الشاشة، التي تعلنه على أنه زر تبديل. أما ToggleGroup حول عدة أزرار فهو شريط أدوات: تعلنه قارئات الشاشة على هذا النحو، وتصل إليه لوحة المفاتيح بوقفة واحدة وتنتقل بين أزرار التبديل فيه بمفاتيح الأسهم. المظهر يعود إلى التطبيق: ليس للـ Toggle ألوان أو أحجام أو أيقونة أو تسمية خاصة به. محتوياته هي أيقونة التطبيق أو تسميته، وتتبع حالته فئات aria-pressed: على الـ Toggle، أو فئات group-aria-pressed: على ما بداخله.
import { Canvas, mount, signal, Text, View } from "nsh";
import { Toggle, ToggleGroup } from "nsh/base";
import { desktop, focusRing, hoverFill, Log, Split, tint, ui } from "./palette";
type Mark = "bold" | "italic" | "underline";
type Align = "left" | "center" | "right";
const marks: { id: Mark; label: string; glyph: string }[] = [
{ id: "bold", label: "Bold", glyph: "B" },
{ id: "italic", label: "Italic", glyph: "I" },
{ id: "underline", label: "Underline", glyph: "U" },
];
const aligns: { id: Align; label: string }[] = [
{ id: "left", label: "Align left" },
{ id: "center", label: "Align center" },
{ id: "right", label: "Align right" },
];
/** Three lines of a paragraph, set as `align` sets them. */
function Lines(props: { align: Align; color: string }) {
return (
<Canvas
style={{ pointerEvents: "none", width: 16, height: 16 }}
draw={(cx) => {
cx.strokeStyle = props.color;
cx.lineWidth = 1.6;
cx.lineCap = "round";
[12, 7, 10].forEach((w, i) => {
const x = props.align === "center" ? 8 - w / 2 : props.align === "right" ? 14 - w : 2;
cx.beginPath();
cx.moveTo(x, 4 + i * 4);
cx.lineTo(x + w, 4 + i * 4);
cx.stroke();
});
}}
/>
);
}
function App() {
const on = signal<Mark[]>(["bold"]);
const align = signal<Align>("left");
const starred = signal(false);
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 3));
const has = (m: Mark) => on().includes(m);
// A toolbar toggle: bare while off, raised while pressed (`pressedStyle`).
const look = (pressed: boolean) => ({
style: { width: desktop ? 28 : 34, height: desktop ? 28 : 34, borderRadius: 7, alignItems: "center", justifyContent: "center" } as const,
class: "transition-colors active:opacity-70 motion-reduce:transition-none",
pressedStyle: ui().raised,
hoverStyle: pressed ? undefined : hoverFill(),
focusStyle: focusRing(),
});
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>toggle</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Click a toggle, or Tab to the toolbar and move with the arrow keys" : "Tap a toggle to turn it on or off"}
</Text>
<Split gap={14} inspector={<Log title={`onChange · ${on().join(", ") || "none"}, ${align()}`} lines={events()} minHeight={76} />}>
{/* One toolbar: the marks any number at once, the alignment exactly one. */}
<ToggleGroup style={{ flexDirection: "row", alignItems: "center", alignSelf: "flex-start", gap: 2, padding: 3, borderRadius: 10, ...ui().inset }}>
<Each of={marks}>
{(m) => (
<Toggle
pressed={has(m().id)}
accessibilityLabel={m().label}
onChange={({ pressed }) => {
on.set(pressed ? [...on(), m().id] : on().filter((x) => x !== m().id));
log(`${m().label}: ${pressed}`);
}}
{...look(has(m().id))}
>
<Text
style={{
fontSize: 14,
color: has(m().id) ? ui().text : ui().muted,
fontWeight: m().id === "bold" ? "800" : "500",
fontStyle: m().id === "italic" ? "italic" : "normal",
textDecorationLine: m().id === "underline" ? "underline" : "none",
}}
>
{m().glyph}
</Text>
</Toggle>
)}
</Each>
<View style={{ width: 1, height: 16, marginHorizontal: 4, backgroundColor: ui().line }} />
<Each of={aligns}>
{(a) => (
<Toggle
pressed={align() === a().id}
accessibilityLabel={a().label}
onChange={({ pressed }) => {
// Exactly one: the pressed alignment stays pressed.
if (pressed) align.set(a().id);
log(`${a().label}: ${pressed}`);
}}
{...look(align() === a().id)}
>
<Lines align={a().id} color={align() === a().id ? ui().text : ui().muted} />
</Toggle>
)}
</Each>
</ToggleGroup>
{/* The text the toolbar formats: the app reads its own values. */}
<View style={{ ...ui().card, borderRadius: 14, padding: 14 }}>
<Text
style={{
fontSize: 15,
lineHeight: 22,
color: ui().text,
fontWeight: has("bold") ? "700" : "400",
fontStyle: has("italic") ? "italic" : "normal",
textDecorationLine: has("underline") ? "underline" : "none",
textAlign: align(),
}}
>
Every toggle reports the value it asks for; the app passes it back.
</Text>
</View>
{/* A toggle on its own, with a label: `aria-pressed:` tints it. */}
<Toggle
pressed={starred()}
onChange={({ pressed }) => {
starred.set(pressed);
log(`Star: ${pressed}`);
}}
class="transition-colors active:opacity-70 motion-reduce:transition-none"
style={{ flexDirection: "row", alignItems: "center", alignSelf: "flex-start", gap: 6, height: desktop ? 28 : 34, paddingHorizontal: 12, borderRadius: 8, borderWidth: 1, borderColor: ui().line }}
pressedStyle={{ backgroundColor: tint(ui().accent, 0.16), borderColor: tint(ui().accent, 0.4) }}
hoverStyle={starred() ? undefined : hoverFill()}
focusStyle={focusRing()}
>
<Text style={{ fontSize: 14, color: starred() ? ui().accent : ui().muted }}>{starred() ? "★" : "☆"}</Text>
<Text style={{ fontSize: 13, fontWeight: "500", color: starred() ? ui().accent : ui().text }}>{starred() ? "Starred" : "Star"}</Text>
</Toggle>
</Split>
</View>
);
}
mount(App);use nsh_base::{theme, toggle, toggle_group};
use nsh_core::prelude::*;
fn formatting() -> impl IntoElement {
let bold = signal(true);
let italic = signal(false);
let item = move |label: &'static str, on: Signal<bool>| {
toggle()
.pressed(on)
.accessibility_label(label)
.on_change(move |pressed: &bool| on.set(*pressed))
.size_8()
.items_center()
.justify_center()
.rounded(move || theme().radius)
.pressed_style(|s| s.bg(move || theme().accent))
.child(text(&label[..1]).text_sm().text_color(move || {
if on.get() { theme().accent_foreground } else { theme().muted_foreground }
}))
};
toggle_group()
.flex_row()
.self_start()
.gap_1()
.child(item("Bold", bold))
.child(item("Italic", italic))
}import { mount, signal, Text } from "nsh";
import { Toggle, ToggleGroup } from "nsh/base";
function App() {
const marks = signal<string[]>(["bold"]);
const item = (value: string, label: string) => (
<Toggle
pressed={marks().includes(value)}
accessibilityLabel={label}
onChange={({ pressed }) =>
marks.set(pressed ? [...marks(), value] : marks().filter((m) => m !== value))
}
class="group size-8 items-center justify-center rounded-(--radius) aria-pressed:bg-accent"
>
<Text class="text-sm text-muted-foreground group-aria-pressed:text-accent-foreground">{label[0]}</Text>
</Toggle>
);
return (
<ToggleGroup class="flex-row self-start gap-1">
{item("bold", "Bold")}
{item("italic", "Italic")}
</ToggleGroup>
);
}
mount(App);Toggle وToggleGroup يأتيان من nsh/base، وهي الحزمة nsh-base في Rust. القيمة تخص التطبيق: يُخبَر كل Toggle إن كان مضغوطًا، وتُبلّغ الضغطة بالقيمة التي تطلبها عبر onChange، ويعرض الـ Toggle ما pressed الذي يمرّره التطبيق إليه. المجموعة أيضًا لا تحتفظ بأي قيمة، لذا فعدد أزرار التبديل التي يمكن ضغطها يعود إلى التطبيق كذلك: أي عدد (عريض ومائل ومسطّر)، أو واحد بالضبط (محاذاة، مبدّل عروض)، حيث يضغط التطبيق الزر المطلوب ويحرر الباقي ويُبقي المضغوط مضغوطًا. في Rust يُنشئ toggle() وtoggle_group() هذه العناصر، مع دالة تعيين لكل خاصية (.pressed(..))، ومستمع لكل حدث (.on_change(..))، ودوال الأنماط المطبوعة، و.child(..) لمحتواها.
#الخصائص
#Toggle
| الخاصية | Rust | النوع | الافتراضي | الوصف |
|---|---|---|---|---|
pressed | .pressed(..) | boolean | false | مفعّل أو غير مفعّل: تقول قارئات الشاشة إنه مضغوط، وتُطبَّق أنماط aria-pressed:. تُبلّغ الضغطة بالقيمة الأخرى عبر onChange؛ ويعرض الـ Toggle القيمة التي يمرّرها التطبيق. |
disabled | .disabled(..) | boolean | false | لا ضغطات ولا استجابة للضغط؛ وتُطبَّق أنماط disabled:، وتقول قارئات الشاشة إنه باهت (معطّل). الـ Toggle داخل ToggleGroup معطّلة يكون معطّلًا أيضًا. |
pressedStyle | .pressed_style(|s| ..) | StyleProp<ViewStyle> | لا شيء | نمط الـ Toggle أثناء الضغط وعدم التعطيل، على شكل فئات aria-pressed:. |
accessibilityLabel | .accessibility_label(..) | string | لا شيء | ما تقوله قارئات الشاشة: ما الذي يفعّله الـ Toggle، حين لا يحتوي محتواه على نص يقول ذلك (أيقونة). |
hitSlop | .hit_slop(..) | number | Partial<Edges> | لا شيء | إلى أي مدى خارج حدوده تُحتسب الضغطات، بالنقاط: رقم واحد لكل الجوانب، أو الجوانب مسمّاة. مثل hitSlop الخاص بالعرض. |
children | .child(..) | JSX.Element | لا شيء | أيقونة التطبيق أو تسميته، وهو ما تقوله قارئات الشاشة. |
class، style | دوال الأنماط | string، StyleProp<ViewStyle> | لا شيء | الـ Toggle نفسه: السطح المحيط بمحتواه. |
activeStyle، hoverStyle، focusStyle، disabledStyle | .active(|s| ..)، .hover(..)، .focus(..)، .disabled_style(..) | StyleProp<ViewStyle> | لا شيء | نمط الـ Toggle أثناء ضغطة جارية، أو تمرير المؤشر، أو التركيز، أو التعطيل، على شكل فئات <state>:. |
#ToggleGroup
| الخاصية | Rust | النوع | الافتراضي | الوصف |
|---|---|---|---|---|
disabled | .disabled(..) | boolean | false | كل Toggle في المجموعة معطّل، أيًّا كانت قيمة disabled الخاصة به. |
children | .child(..) | JSX.Element | لا شيء | أزرار التبديل في المجموعة، وأي شيء يضعه التطبيق بينها (فاصل). |
class، style | دوال الأنماط | string، StyleProp<ViewStyle> | لا شيء | تخطيط المجموعة ومظهرها: عمود افتراضيًا، وflex-row للصف. |
#الأحداث
| الحدث | Rust | الوصف |
|---|---|---|
onChange (Toggle) | .on_change(|pressed: &bool| ..) | ضُغط الـ Toggle: نقرة لمسية، أو نقرة بالفأرة، أو Space أو Return أثناء امتلاكه تركيز لوحة المفاتيح، أو تفعيل من قارئ شاشة. { pressed } هي القيمة التي يطلبها، وهي عكس pressed. يعمل قبل أن يتغير أي شيء: يعرض الـ Toggle ما pressed الذي يمرّره التطبيق. |
مفتاح السهم في ToggleGroup ينقل تركيز لوحة المفاتيح فقط: ولا يضغط شيئًا. الـ Toggle المعطّل لا يبلّغ عن شيء.
#الأجزاء
| الجزء | داخل | يُنسَّق بواسطة | الحالات |
|---|---|---|---|
| المجموعة | ToggleGroup: class، style | ||
| الـ Toggle | المجموعة، أو في أي مكان | Toggle: class، style | aria-pressed:، disabled:، active:، hover:، focus: (أو pressedStyle، disabledStyle، …) |
| الأيقونة أو التسمية | الـ Toggle | عناصرها الخاصة (children) | group-aria-pressed:، group-disabled: وسائر متغيرات group-*: مع group على class الخاص بالـ Toggle |
aria-pressed: تعني أن الـ Toggle مفعّل؛ وactive: تعني ضغطة جارية، سواء كان مفعّلًا أم لا. أظهر الـ Toggle المضغوط عبر سطحه نفسه: تعبئة، أو حبة مرتفعة، أو أيقونة أقوى (انظر أدلة التصميم).
#Segmented
المجموعة التي تكون أزرار التبديل فيها متصلة ويُضغط واحد منها في كل مرة هي عنصر تحكم مقسّم (مبدّل عروض): يحتفظ التطبيق بقيمة واحدة ويضغط الـ Toggle الذي يحمل اسمها. المظهر المتصل هو فئات التطبيق:
<ToggleGroup class="flex-row self-start">
<Each of={["grid", "list"]}>
{(value) => (
<Toggle
pressed={view() === value()}
accessibilityLabel={`${value()} view`}
onChange={({ pressed }) => pressed && view.set(value())}
class="-ml-px h-8 px-3 border border-border first:ml-0 first:rounded-l-(--radius) last:rounded-r-(--radius) aria-pressed:bg-accent"
>
<Text class="text-sm">{value()}</Text>
</Toggle>
)}
</Each>
</ToggleGroup>
#معطّل
الـ Toggle المعطّل لا يقبل ضغطات، وتعلنه قارئات الشاشة على أنه باهت. تُطبَّق فئات disabled: الخاصة به فوق فئات aria-pressed:، وتتبعها فئات group-disabled: بداخله. disabled على ToggleGroup يعطّل كل Toggle فيها، أي شريط أدوات يقفله التطبيق:
<ToggleGroup disabled={readonly()} class="flex-row gap-1">
<Toggle pressed={bold()} onChange={({ pressed }) => bold.set(pressed)} class="size-8 disabled:opacity-50">
<Text>B</Text>
</Toggle>
</ToggleGroup>
#هدف صغير مع hitSlop
قد يكون الـ Toggle ذو الأيقونة أصغر من إصبع. hitSlop تجعل الضغطات التي تقع بالقرب منه تُحتسب، بالنقاط بعد كل جانب، دون تغيير تخطيطه أو مظهره: رقم واحد لكل الجوانب، أو الجوانب مسمّاة ({ top: 8, bottom: 8 })، كما يقبلها hitSlop الخاص بالعرض.
<Toggle
pressed={favorite()}
hitSlop={12}
accessibilityLabel="Favorite"
onChange={({ pressed }) => favorite.set(pressed)}
class="size-5 items-center justify-center"
>
<Text>★</Text>
</Toggle>
#إمكانية الوصول
- الـ Toggle عنصر واحد لقارئات الشاشة، بالدور
"button"وحالة الضغط: زر تبديل. تسميته هي نص محتوياته، أوaccessibilityLabel. ToggleGroupلها الدور"toolbar": تسمع قارئات الشاشة أزرار التبديل بداخلها على أنها شريط أدوات واحد، مع بقاء كل منها عنصرًا مستقلًا.disabledيُعلَن بالطريقة التي تعلنه بها المنصة (VoiceOver وTalkBack وUI Automation وشجرة إمكانية الوصول في المتصفح).- على macOS والويب تكون المجموعة وقفة واحدة في تركيز لوحة المفاتيح، وتنقل مفاتيح الأسهم بين أزرار التبديل فيها (أدناه).
راجع إمكانية الوصول للاطلاع على التسميات والأدوار والإعلانات عمومًا.
#لوحة المفاتيح
على macOS والويب، حيث يصل Tab إلى العروض التي تقبل الضغطات، تتبع ToggleGroup نمط شريط الأدوات في WAI-ARIA:
| المفتاح | ما يفعله |
|---|---|
| Tab، Shift+Tab | يدخل المجموعة عند أول Toggle مضغوط (أو أول Toggle غير معطّل إن لم يكن أي منها مضغوطًا)، ويغادرها بخطوة واحدة. |
| Right، Down | ينقل التركيز إلى الـ Toggle التالي، ومن الأخير إلى الأول. |
| Left، Up | ينقل التركيز إلى الـ Toggle السابق، ومن الأول إلى الأخير. |
| Home، End | ينقل التركيز إلى أول Toggle أو آخره. |
| Space، Return | يضغط الـ Toggle الذي عليه التركيز. |
تتخطى مفاتيح الأسهم أزرار التبديل المعطّلة ولا تضغط شيئًا: لا يتغير الـ Toggle إلا عند ضغطه. يعمل زوجا الأسهم كلاهما أيًّا كان تخطيط المجموعة، وهو من فئات التطبيق. في التخطيط من اليمين إلى اليسار يتبادل Left وRight موضعيهما: ينتقل Left إلى الـ Toggle التالي وRight إلى السابق؛ أما Up وDown وHome وEnd فتبقى كما هي. تُظهر أنماط focus: موضع التركيز. وToggle
خارج ToggleGroup وقفة مستقلة بذاتها.
#ملاحظات المنصات
- iOS وAndroid وMac Catalyst: الضغطة هي نقرة لمسية تنتهي على الـ Toggle، بما في ذلك محتواه.
- أنظمة سطح المكتب (macOS وWindows وLinux وGPUI) والويب: الضغطة هي نقرة. على macOS والويب، يضغط Space وReturn أيضًا الـ Toggle الذي عليه تركيز لوحة المفاتيح، وتنقل مفاتيح الأسهم وHome وEnd التركيز (لوحة المفاتيح).
- كل منصة ترسم ما ينسّقه التطبيق: أزرار التبديل، وسطح الأزرار المضغوطة، ومسار المجموعة.