جداول html ذات رأسية وأعمدة ثابتة باستخدام jQuery


نذير صغير

رؤوس الجداول الثابتة ليست بالأمر الجديد في مواقع الويب. على عكس الورق، حيث يستطيع القارئ نقل نظره بسرعة إلى أعلى الشاشة ليعرف في أي عمود هو، لكن أبعاد الشاشة تجعل من قراءة الجداول الطويلة أمر صعبا.

sticky-headers-jquery_(1).thumb.png.371a

رؤوس الجداول الثابتة، كما يشير اسمها، تبقى ثابتة في أعلى الجدول حتى وإن نزلنا أكثر في الجدول. يساعد الأمر في إبقاء أسماء الأعمدة دوما في متناول اليد، حتى لا يجبر المستخدم على الرجوع إلى أعلى الجدول كل مرة من أجل النظرة ثم الرجوع مجددا.

يوجد العديد من سكربتات وإضافات jQuery التي تعمل بطريقة فعالة وخالية من الأخطاء، فهم ليسوا الحل المثالي لجميع المشاكل الممكنة، ففي بعض الحالات، على الجداول أن تتبع قواعد هيكلة لم تحسب لها الإضافات حساب، كالجداول التي تسمح بإظهار مؤشر التحرك (scroll bar) عندما لا تكفي المساحة لإظاهر الجدول.

هذا المقال لن يكون الحل المثالي لجميع المواقف، ولكنه سيكون حلا لأغلب المشاكل الشائعة.

حل باستخدام CSS فحسب عبر position: sticky؟

تملك CSS حلا مناسبا لهذه المشكلة وهو عبر استخدام postion: sticky. لكن للأسف، الحل غير مدعوم في chrome رغم أنه كان مدعوما سابقا، إلا أنّ فريق التطوير قام بإلغاء الخاصية تماما منه إلى أجل غير معلوم. ولأننا لا نستطيع التضحية بكل مستخدمي متصفح chrome فعلينا إيجاد حل بديل للمشكلة.

حل عبر استخدام jQuery

حل jQuery هو بسيط جدا، لكن قبل أن نبدأ باستخدام، علينا أن نلقي نظرة على كيف يكون جدول ما صحيحا من حيث الهيكلة:

<table>
    <thead>
        <tr>
            <th></th>
            <!-- more columns are possible -->
        </tr>
    </thead>
    <tbody>
        <tr>
            <td></td>
            <!-- more columns are possible -->
        </tr>
        <!-- more rows are possible -->
    </tbody>
    <tfoot><!-- هذا الجزء اختياري -->
        <tr>
            <td></td>
        </tr>
    </tfoot>
</table>

ما الذي نحاول تحقيقه؟

سنحاول جعل السكربت يدعم أغلب المشاكل الشائعة والتي هي:

  • الاستخدام الأساسي: رأس الجدول يكون ثابتا.
  • رؤوس الجداول الأفقية والعمودية.
  • الجداول العريضة:
    • العمود الأفقي: إذا كان هنالك العديد من الأعمدة التي لا يمكن إظهارها في عرض الصفحة، فسنستخدم عمودا جانبيا ثابتا.
    • الصف العمودي: وهو الاستخدام الأساسي، أن يكون الرأس العلوي ثابتا عند النزول بالجدول.
    • كلا العمودين: حيث نثبت كل من العمود والصف.

CSS من أجل البدء

رغم أننا سنستخدم حلاّ عبر جافاسكربت، فإن CSS ضرورية من أجل تنفيذ الأمر:

.sticky-wrap {
    overflow-x: auto;
    position: relative;
    margin-bottom: 1.5em;
    width: 100%;
}
.sticky-wrap .sticky-thead,
.sticky-wrap .sticky-col,
.sticky-wrap .sticky-intersect {
    opacity: 0;
    position: absolute;
    top: 0;
    left: 0;
    transition: all .125s ease-in-out;
    z-index: 50;
    width: auto; /* Prevent table from stretching to full size */
}
    .sticky-wrap .sticky-thead {
        box-shadow: 0 0.25em 0.1em -0.1em rgba(0,0,0,.125);
        z-index: 100;
        width: 100%; /* Force stretch */
    }
    .sticky-wrap .sticky-intersect {
        opacity: 1;
        z-index: 150;
    }
    .sticky-wrap .sticky-intersect th {
        background-color: #666;
        color: #eee;
    }
.sticky-wrap td,
.sticky-wrap th {
    box-sizing: border-box;
}

ملاحظة: من المهم جدا نقل كل CSS الخاصة بوسم <table> إلى sticky-wrap. هذا حتى يتاح لنا التحكم بها مباشرة عبر jQuery.

لنقل أنك تملك CSS التالي:

table {
    margin: 0 auto 1.5em;
    width: 75%;
}

كل ما عليك فعله ببساطة هو نقلها إلى sticky-wrap. :

.sticky-wrap {
    overflow-x: auto; /* Allows wide tables to overflow its containing parent */
    position: relative;
    margin: 0 auto 1.5em;
    width: 75%;
}

استخدام جافاسكربت

سوف نقوم بتنفيذ دالتنا على كل جدول موجود في الصفحة، الأهم من هذا سنتفقد إن كان الجدول يملك <thead> وإن كان هذا الأخير يحتوي على الأقل على <th> واحد. إن لم تتحق الشروط، فستتجاهل دالتنا هذا الجدول.

$(function () {
    // هنا نقوم باختيار جميع الجداول في الصفحة
    // لكنك حر بتحديد الجداول التي تريدها
    $('table').each(function () {
        if($(this).find('thead').length > 0 && $(this).find('th').length > 0) {
            // بقية السكربت تكون هنا
        }
    });
});

الخطوة 1: نسخ عنصر <thead>

// تحديد المتغيرات وبعض الاختصارات
    var $t     = $(this),
        $w     = $(window),
        $thead = $(this).find('thead').clone(),
        $col   = $(this).find('thead, tbody').clone();

الخطوة 2: تغليف الجدول ونسخه

من أجل دعم الحالات التي يكون فيها الجدول أعرض من ماهو مسموح (أي عندما يكون عندما عدد كبير من الأعمدة، أو أعمدة طويلة، فنحوي الجدول في <div> حتى نسمح لك بأن يكون scrollable على المحور الأفقي:

// احتواء الجدول
$t
.addClass('sticky-enabled')
.css({
    margin: 0,
    width: '100%';
})
.wrap('<div class="sticky-wrap" />');

// تفقد إن كنا قد حددنا بأن يكون الجدول قابلا للتمرير (scroll) على المحور الأفقي
if($t.hasClass('overflow-y')) $t.removeClass('overflow-y').parent().addClass('overflow-y');

// صنع رأس جدول جديد بصنف .stiky-head
$t.after('<table class="sticky-head" />')

// إذا كان <tbody> يحتوي على <th> فنقوم بصنع عمود جديدة ليكون الخانة أعلى الجدول
if($t.find('tbody th').length > 0) {
    $t.after('<table class="sticky-col" /><table class="sticky-intersect" />');
}
// اختصارات
var $stickyHead  = $(this).siblings('.sticky-thead'),
    $stickyCol   = $(this).siblings('.sticky-col'),
    $stickyInsct = $(this).siblings('.sticky-intersect'),
    $stickyWrap  = $(this).parent('.sticky-wrap');

الخطوة 3: وضع محتوى الجداول المنسوخة

ما سنقوم به الآن هو أخذ المحتوى المنسوخ من الجدول الأصلي ووضعه في الجداول الجديدة التي ستكون ملتصقة:

  1. رأس الجدول الجديد سيستلم كامل المحتوى من عنصر <thead> المنسوخ.
  2. الأعمدة الملتصقة ستستلم المحتوى من أول عنصر <th> من <thead> وكل عناصر <th> المتبقية من <tbody>.
  3. اندماج العمود مع الصف (أيّ الخانة المشتركة بين العمود والصف) ستأخذ محتوى من خلال أعلى خانة على يمين الجدول (بافتراض أننا نتعامل مع الصفحة على أساس RTL).
// StickyHead يحصل على المحتوى من <thead>

    $stickyHead.append($thead);

    $stickyCol
    .append($col)
        .find('thead th:gt(0)').remove()
        .end()
        .find('tbody td').remove();

    // StickyIntersect يحصل على المحتوى من <th> في <thead>

    $stickyInsct.html('<thead><tr><th>'+$t.find('thead th:first-child').html()+'</th></tr></thead>');

الخطوة 4: الدوال

هنا يأتي أهم جزء من السكربت الخاص بنا، سنحدد أيّ دوال يجب أن تنفذ من أجل أن يعمل السكربت بشكل صحيح:

  1. دالة من أجل تحديد عرض عناصر <th> في رأس الجدول المنسوخ، بما أننا نسخنا عنصر <thead> فحسب، فعرض رأس الصفحة المنسوخ الكلي لن يكون عرض رأس الصفحة الفعلي، لأن عرض <tbody> لم يتم إضافته حيث لا نعلم هل سيؤثر على رأس الصفحة أو لا.
  2. دالة من أجل تحديد مكان رأس الصفحة الثابت حتى نقوم بتحديث بُعد رأس الصفحة المنسوخ الأفقي، الذي قمنا بتحديد position: absolute عندما نبدأ بتمرير شريط التقدم داخل الجدول.
  3. دالة من أجل تحديد مكان العمود الجانبي الثابت ولها نفس حالة تثبيت رأس الصفحة.
  4. دالة من أجل حساب المساحة المتبقية وسنقوم بشرح هذه الدالة لاحقا بشكل أعمق.

        // Function 1: setWidths()
    // Purpose: To set width of individually cloned element
    var setWidths = function () {
            $t
            .find('thead th').each(function (i) {
                $stickyHead.find('th').eq(i).width($(this).width());
            })
            .end()
            .find('tr').each(function (i) {
                $stickyCol.find('tr').eq(i).height($(this).height());
            });
    
    
            // Set width of sticky table head
            $stickyHead.width($t.width());
    
    
            // Set width of sticky table col
            $stickyCol.find('th').add($stickyInsct.find('th')).width($t.find('thead th').width())
    
    
        },
    // Function 2: repositionStickyHead()
    // Purpose: To position the cloned sticky header (always present) appropriately
        repositionStickyHead = function () {
            // Return value of calculated allowance
            var allowance = calcAllowance();
    
    
            // Check if wrapper parent is overflowing along the y-axis
            if($t.height() > $stickyWrap.height()) {
                // If it is overflowing
                // Position sticky header based on wrapper's scrollTop()
                if($stickyWrap.scrollTop() > 0) {
                    // When top of wrapping parent is out of view
                    $stickyHead.add($stickyInsct).css({
                        opacity: 1,
                        top: $stickyWrap.scrollTop()
                    });
                } else {
                    // When top of wrapping parent is in view
                    $stickyHead.add($stickyInsct).css({
                        opacity: 0,
                        top: 0
                    });
                }
            } else {
                // If it is not overflowing (basic layout)
                // Position sticky header based on viewport scrollTop()
                if($w.scrollTop() > $t.offset().top && $w.scrollTop() < $t.offset().top + $t.outerHeight() - allowance) {                 // When top of viewport is within the table, and we set an allowance later
                    // Action: Show sticky header and intersect, and set top to the right value
                    $stickyHead.add($sticktInsct).css({
                        opacity: 1,
                       top: $w.scrollTop() - $t.offset().top
                    });
                 } else {
                     // When top of viewport is above or below table
                     // Action: Hide sticky header and intersect
                     $sticky.add($stickInsct).css({
                         opacity: 0,
                         top: 0
                     });
                 }
            }
        },
    // Function 3: repositionStickyCol()
    // Purpose: To position the cloned sticky column (if present) appropriately
        repositionStickyCol = function () {
            if($stickyWrap.scrollLeft() > 0) {
                // When left of wrapping parent is out of view
                // Show sticky column and intersect
                $stickyCol.add($stickyInsct).css({
                    opacity: 1,
                    left: $stickyWrap.scrollLeft()
                });
            } else {
                // When left of wrapping parent is in view
                // Hide sticky column but not the intersect
                // Reset left position
                $stickyCol
                .css({ opacity: 0 })
                .add($stickyInsct).css({ left: 0 });
            }
        },
    // Function 4: calcAllowance()
    // Purpose: Return value of calculated allowance
         calcAllowance = function () {
             var a = 0;
    
    
             // Get sum of height of last three rows
             $t.find('tbody tr:lt(3)').each(function () {
                 a += $(this).height();
             });
    
    
             // Set fail safe limit (last three row might be too tall)
             // Set arbitrary limit at 0.25 of viewport height, or you can use an arbitrary pixel value
             if(a > $w.height()*0.25) {
                a = $w.height()*0.25;
            }
    
    
            // Add height of sticky header itself
            a += $sticky.height();
    
    
            return a;
        };
    }

والآن سنقوم بشرح ما قمنا به في الدالة الرابعة، نحن لا نريد من رأس الجدول أن يلحقنا إلى أسفل الجدول، فالأمر غير ضروري وقد يغطي لنا آخر سطر من الجدول، لذا من الضروري إبقاء مساحة فارغة في الأسفل.

حسب ما جربت، فقد اكتشفت أننا لا نحتاج لرأس الجدول عندما نصل لأخر 3 سطور من الجدول لأن تركيزنا انتقل على المحتوى الآن.

$t.find('tbody tr:lt(4)').each(function () {
    allowance += $(this).height();
});

الخطوة 5: ربط كل شيء

والآن قد انتهينا من تعريف كل الدوال اللازمة، كل ما تبقى هو ربط المتفقدات أو (Event handlers) مع عنصر(window)$.

  1. عندما يجهز الـDOM نقوم بالحسابات الأولية للعرض.
  2. عندما تحمل كامل المعلومات نقوم بحساب الأبعاد مرة أخرى، هذه الخطوة مهمة لأن جدولك قد يحتوي على أشياء تحمل بعد الـ DOM مثل الصور وخطوط الويب.
  3. عندما يتم التمرير في الحاوي الرئيسي ولكن هذا سيحدث في حالة كان المحتوى أكبر من عرض الحاوي، حينها نريد إعادة تغيير مكان العمود الرئيسي.
  4. عندما يتم تصغير نافذة المتصفح نريد إعادة حساب العرض.
  5. عندم يتم النزول في المتصفح نريد أن نغير مكان رأس الجدول.

يمكن تلخيص ما قلناه للتو في الكود التالي. يجدر الذكر أن أحداث التصغير والتمرير يتم التحكم بهما باستخدام إضافة throttle+debounce.

// #1: DOMعندما يجهز الـ
setWidths();

// #2: نراقب الحاوي في حال حدوث تمرير فيه
$t.parent('.sticky-wrap').scroll($.throttle(250, function() {
    repositionStickyHead();
    repositionStickyCol();
}));

// الآن نربط ما قمنا بعنصر $(window)
$w
// #3: عندما يتم تحميل كامل المحتويات
.load(setWidths)
// #4: عندما يتم تصغير النافذة
// قمنا باستخدام throttle هنا حتى لا يتم إطلاق الحدث أكثر من مرة (في الوضع الافتراضي يتم إطلاق الحدث من أجل كل جزء يتم تصغيره، هنا ننتظر حتى ينتهي التصغير كاملا ثم نطلق)
.resize($.throttle(250, function () {
    setWidths();
    repositionStickyHead();
    repositionStickyCol();
})
// #5: عندما يتم النزول في النافذة
// استخدمنا throttle حتى لا يتم إطلاق الدالة كثيرا
.scroll($.throttle(250, repositionStickyHead);

وانتيهنا، كان هذا كل شيء!

النقاش

نحن نعرف أن لاشيء كامل، لذا سنناقش الطرق الأخرى التي تملك محاسن على هذه الطريقة ومساوئها ولم استخدمنا هذه الطريقة.

استخدام position: fixed

استخدام هذه الطريقة قد يبدو مغريا لسبيين:

  1. لا يوجد حاجة لحسابات من أجل رأس الجدول، لأن العنصر المثبت (fixed) يقع خارج الصفحة الفعلية وسيبقى ثابتا في مكانه.
  2. نتجنب البطء في الحسابات هذا لأن العناصر الثابتة تلاحق الجدول ولا تثبت معه، لأننا نقوم بالحساب في فترات ثابتة (عبر throttle) وبالتالي سيظهر أن العنصر الثابت غير متجاوب وبالتالي غير طبيعي.

لكن المشكلة في هذه الطريقة هي أننا نزيل العنصر من رونق الصفحة، ففي حالة تجاوز عرض الجدول العرض المسموح وأصبح من الضروري إضافة scroll فإن رأس لن يلحق المحتوى على المحور الأفقي لأنه ثابت في الصفحة. وهذا سبب كبير لا يسمح لنا باستخدام أغلب إضافات jQuery التي تقدم هذه الخواص. وكتبت هذه الدورة من أجل إصلاح هذا الأمر بالتحديد.

استخدام position: sticky

الخاصية الجديدة مناسبة للأمر، في الواقع لقد بنيت من أجل هذا الأمر في الحسبان، المشكلة فيها أنها غير مدعومة من متصفح chrome (سبق وكانت مدعومة ولكن أزيل الدعم كليا) وبذلك تفقد كل الزوار من هذا المتصفح.

مثال حي:

See the Pen avovoo by Hsoub Academy (@HsoubAcademy) on CodePen.

ترجمة -وبتصرف- للمقال: Sticky Table Headers & Columns لصاحبه Terry Mun.



1 شخص أعجب بهذا


تفاعل الأعضاء


لا توجد أيّة تعليقات بعد



يجب أن تكون عضوًا لدينا لتتمكّن من التعليق

انشاء حساب جديد

يستغرق التسجيل بضع ثوان فقط


سجّل حسابًا جديدًا

تسجيل الدخول

تملك حسابا مسجّلا بالفعل؟


سجّل دخولك الآن