تخطي إلى المحتوى

البطاقات والأشرطة والبحث

هذه الصفحة مترجمة من الإنجليزية. اقرأ الصفحة الإنجليزية

الصفحات العامة مثل المتجر تعرض الصفوف على شكل بطاقات بدل جدول. تحمّل CardGrid وRail وSearchBox صفوفها عبر النوع نفسه من الإجراءات الذي تستخدمه DataGrid: يأخذ DataQuery ويعيد Page. يجري الترقيم والفرز ونص البحث على الخادم، وتحتاج القراءة إلى وصول الإجراء، كما في DataGrid.

#[action(public)]
pub async fn catalog_products(q: DataQuery, store: ProductStore) -> Result<Page<Product>> {
    Ok(q.apply(store.all()?.into_iter().filter(|p| p.active).collect()))
}

شبكة البطاقات

CardGrid::of(catalog_products)
    .title(t!("shop.products"))
    .card(|p: &Product| {
        Card::image(p.image.clone(), p.name.clone())
            .text(p.price.to_string())
            .badge(Badge::new(t!("shop.new")).tone(Tone::Success))
            .link(shop_product, ProductPage { id: p.id })
    })
    .sort_option(t!("shop.sort.name"), Product::name, false)
    .sort_option(t!("shop.sort.cheapest"), Product::price, false)
    .image_size(300, 300)
    .paging(Paging::infinite(24))
  • البطاقات. تصنع Card::image(image, title) بطاقة واحدة من صف، مع text وbadge وalt وlink (شاشة ومعاملاتها؛ البطاقة كلها رابط واحد وتُفتح دون إعادة تحميل الصفحة). الصور تُحمَّل كسولًا (lazy)، بالعرض والارتفاع من image_size حتى لا تقفز الصفحة. النص البديل للصورة فارغ، لأن العنوان المجاور لها يسمّي البطاقة؛ اضبط alt عندما تقول الصورة أكثر من ذلك. عناوين الصور غير المسار أو https: أو data:image/ تُحذف.
  • المزيد على البطاقة. يضيف subtitle سطرًا ثانيًا أهدأ لونًا تحت العنوان (مؤلف، فئة). يضع corner(badge) شارة فوق الزاوية الأخيرة للصورة، بجوار badge في الزاوية الأولى؛ ويضيف badges([...]) صفًا من الشارات تحت النص، مثل الوسوم. يرسم progress(fraction, label) شريطًا رفيعًا على طول الحافة السفلية للصورة ليبيّن مقدار ما أُنجز: تتراوح النسبة بين 0 و1، وتقرأ قارئات الشاشة التسمية والنسبة المئوية (role="progressbar" مع aria-valuenow). يرتّب horizontal() البطاقة في صف، بصورة صغيرة في البداية والنص بجانبها؛ وتمنح شبكة البطاقات أو rail التي تحوي مثل هذه البطاقات أعمدة أوسع. تعمل كلها في CardGrid وRail.
Card::image(c.cover.clone(), c.title.clone())
    .subtitle(c.teacher.clone())
    .corner(Badge::new(t!("courses.new")).tone(Tone::Success))
    .badges(c.tags.iter().map(|t| Badge::new(t.clone())))
    .progress(c.done, t!("courses.done"))
    .horizontal()
  • الصفحة الأولى على الخادم. الصفحة الأولى (24 بطاقة في شبكة البطاقات و12 في rail، أو الحجم المحدد في paging) جزء من HTML الصفحة، فترى محركات البحث والزوار بدون JavaScript البطاقات وروابطها، وتحمل الصور أبعادها فلا يتحرك شيء أثناء تحميلها. تأتي الصفحات التالية من المتصفح. تبقى الصفحة العامة HTML نفسه لكل زائر، لذا يمكن تخزينها مؤقتا. البطاقة المرتبطة بـ screen له route ترتبط بعنوانه القانوني (/items/{slug}) وليس بإعادة توجيه.
  • الترقيم. Paging::infinite(n) (الافتراضي، 24) يعرض زر "Show more" الذي يعمل أيضًا عندما يظهر عند التمرير؛ وينتقل التركيز إلى أول بطاقة جديدة عند الضغط على الزر. Paging::pages(n) يعرض زري السابق والتالي ورقم الصفحة. يحدد الخادم حجم الصفحة؛ ولا يقول العميل سوى من أين يتابع.
  • الفرز. كل sort_option ترتيب يمكن للمستخدمين اختياره من قائمة منسدلة؛ والأول هو الترتيب الذي تبدأ به البطاقات. لا يقبل الخادم إلا هذه الترتيبات، ويُسقط المرشحات ونص البحث التي يرسلها العميل.
  • البحث. .search(text) يعرض الصفوف المطابقة للنص الحر (DataQuery::q) فقط، مثل نص شاشة نتائج.
  • الحقول المقنّعة. تُبنى البطاقات بدالة مغلقة (closure) ترى الصف الخام. بالنسبة إلى نموذج فيه حقول #[mask]، لا يحصل على البطاقات إلا المستخدمون الذين يحق لهم كشفها؛ أما الباقون فيحصلون على Forbidden. استخدم نموذجًا بلا حقول مقنّعة للبطاقات العامة.

المعاينة عند التمرير

يعرض .preview(screen, params) على ImageCard أو ListItem له رابط تلك الشاشة في لوحة بجانب البطاقة: بعد نحو نصف ثانية من تمرير الفأرة، أو فورًا عندما تنال البطاقة تركيز لوحة المفاتيح. تُحمَّل الشاشة عندها فقط، من GET /_zeyn/screen/... وبفحوص الوصول نفسها للنافذة، وتُحفظ كل معاينة طوال عمر الصفحة (24 كحد أقصى، وتُمسح عندما يفتح nav.js شاشة أخرى). تُزال الإطارات والكائنات ومشغلات الوسائط والسكربتات قبل ظهور اللوحة، فلا تحمّل المعاينة iframe أبدًا. تعيش اللوحة في الطبقة العليا، فلا تقصّها الـ rails والشبكات التي تقص محتواها؛ وتُفتح في الجهة الأوسع، وتنقلب عند حواف الشاشة، وتمرَّر داخل نفسها ولا تمرّر الصفحة أبدًا. وهي تتبع WCAG 1.4.13: يمكن للمؤشر الانتقال إلى اللوحة، ويغلقها Escape، وتبقى حتى يغادر المؤشر أو التركيز. وعلى شاشات اللمس والمؤشرات الخشنة لا توجد معاينة ويفتح النقر الرابط؛ ولا يُحمَّل السكربت (preview.js، 2.5 KB gzip) هناك أبدًا، وفي غير ذلك يُحمَّل عند أول تمرير أو تركيز على بطاقة كهذه. ومع الحركة المخفضة تظهر اللوحة دون حركة التكبير القصيرة.

Rail::of(search_products).card(|p: &Product| {
    Card::image(p.image.clone(), p.name.clone())
        .link(product, ProductParams { id: p.id })
        .preview(product_summary, ProductParams { id: p.id })
})

الشريط (Rail)

الشريط شبكة بطاقات مرتبة في صف واحد يتمرر جانبيًا، مع التقاط عند التمرير (scroll snapping) وزري السابق والتالي اللذين يمرران بعرض عرض واحد (بسلاسة ما لم يطلب المستخدم تقليل الحركة). يحمّل الدفعة التالية، 12 في كل مرة، عندما يقترب المستخدم من نهايته. البطاقات روابط ضمن ترتيب Tab وتظهر في العرض عندما تنال التركيز.

شريط التمرير مخفي؛ ويظل التمرير ممكنا باللمس ولوحة اللمس وShift مع عجلة الفأرة، و.scrollbar(true) يبقي شريطا رفيعا. يمكن الضغط على rail بالفأرة وسحبه جانبا. بعد تحريك يزيد على 5 بكسل تُلغى النقرة عند الإفلات، فلا تُفتح البطاقة التي تحت المؤشر، ويستقر rail عند أقرب بطاقة؛ أما الحركة الأصغر فهي نقرة عادية. أثناء السحب لا يُحدد النص ولا تُسحب الصور إلى الخارج ولا تُفتح معاينة البطاقة. على شاشات اللمس يبقى تمرير المتصفح نفسه. زرا السابق والتالي موجودان دائما ويُعطلان عند الطرفين، لأن شريط التمرير المخفي لا يتيح التمرير بلوحة المفاتيح. بدون JavaScript يُخفى الزران ويُمرر rail بالطريقة الأصلية للمتصفح.

Rail::of(catalog_products)
    .title(t!("shop.new_arrivals"))
    .card(|p: &Product| Card::image(p.image.clone(), p.name.clone()))
    .sort_option(t!("shop.sort.newest"), Product::id, true)

حقل بحث مع اقتراحات، يتبع نمط combobox من W3C APG مع قائمة منبثقة (list box). عندما يكتب المستخدم ينتظر instant ميلي ثانية ثم يطلب الاقتراحات من الخادم: يعمل الإجراء مع النص بوصفه DataQuery::q، ويعود نص اقتراح كل صف (أول حقل نصي غير مقنّع فيه ما لم يحدد suggestion غير ذلك). يفتح Enter، أو اختيار اقتراح، شاشة النتائج مع النص، دون إعادة تحميل الصفحة.

#[screen(route = "/shop/search", public)]
pub fn shop_search(p: SearchParams) -> Screen {
    Screen::new(t!("shop.search.all"))
        .body(SearchBox::of(catalog_products).value(p.q.clone()).results(shop_search))
        .body(CardGrid::of(catalog_products).card(product_card).search(p.q).paging(Paging::pages(12)))
}

SearchBox::of(catalog_products)
    .label(t!("shop.search.label"))
    .instant(150)
    .suggestion(|p: &Product| p.name.clone())
    .results(shop_search)

لوحة المفاتيح: يتنقل السهمان لأسفل ولأعلى بين الاقتراحات (السهم لأسفل يفتحها)، ويبحث Enter بالاقتراح المميز أو بالنص المكتوب، ويغلق Escape الاقتراحات ثم يمسح الحقل. تخبر منطقة حية (live region) قارئات الشاشة بعدد الاقتراحات.

تأخذ شاشة النتائج SearchParams (q، فارغ عندما تُفتح الشاشة من قائمة). يقص الخادم النص عند MAX_SEARCH_QUERY (100) حرف، ويقرر عدد الاقتراحات المرسلة (8 ما لم يحدد limit غير ذلك، وحد أقصى 20)، ويتحقق من وصول الشاشة وحراس الميزة (guards) وصلاحية الإجراء، ويحسب كل استدعاء اقتراحات ضمن حد معدل الكتابة (App::write_limit).

النص الحر

DataQuery::q نص حر: الصفوف التي تحتوي حقولها النصية عليه، مع تجاهل حالة الأحرف. يطابق الحرف التركي ذو النقطة وبلا نقطة (İ, ı) الحرف i، فيجد "istanbul" النص "İSTANBUL". الحقول ذات #[mask] لا يُبحث فيها أبدًا، والنص الفارغ يطابق كل الصفوف، والنص الأطول من 100 حرف يُتجاهل. تقرؤه DataQuery::apply وطبقات بيانات SQL (LOWER(TRANSLATE(column, 'İı', 'ii')) LIKE ... مع معاملات مربوطة) بالطريقة نفسها.