momente şi schiţe de informatică şi matematică
anti point—and—click

Plugin jQuery pentru paginarea unui tabel HTML

jQuery | paginare | plugin
2009 aug

Am avut de redat tabele de date (mai ales pe "http://salar.sitsco.com" —desfiinţat în 2011) de până la 100–150 de rânduri şi am folosit pentru ele scriptul paging.js preluat (prin 2006) din "http://it.newinstance.it/2006/09/27/client-side-html-table-pagination-with-javascript/", agreindu-l tocmai pentru simplitate (din contră, unii au cerut în comentarii: "made this pagination like the google one"; "add column sorting" etc.). Dar paging.js este totuşi defectuos în privinţa manierei de folosire - încât în cele din urmă, ne-am propus să-l rescriem (folosind jQuery).

Legături stângace

În comentariile citate se propun (cam într-o doară…) diverse adăugiri, ignorând în fond un principiu de bun-simţ: un anumit instrument trebuie folosit când este cazul şi nu în toate cazurile posibile. Iar până a-i extinde domeniul, ar trebui observat dacă el este suficient de bine conceput pentru sarcina de bază care i-a fost atribuită.

Pot exista chiar defecte evidente, care pot fi uşor corectate. De exemplu, în sursa paging.js (vezi eventual, articolul citat) se constituie în mod repetat, un tablou pentru rândurile tabelului: var rows = document.getElementById(tableName).rows; - în metoda init(), dar apoi şi în metoda showRecords() care este apelată de fiecare dată când trebuie afişată o pagină a tabelului. Probabil s-a vrut să se aibă în vedere şi tabele "dinamice" (în care se pot şterge/adăuga rânduri pe parcurs) - dar scriptul respectiv nu vizează prin nimic altceva, acest caz. Dar aici, avem de relevat un defect de altă natură.

Structura HTML vizată de paging.js constă în două elemente: <table id="torar"> (conţinând rândurile <tr> ale tabelului de paginat), urmat imediat de un <div id="navorar"> </div> - menit să conţină "bara de navigare" produsă de către script; în final, browserul va arăta tabelul sub forma:

pgt

Desigur, se putea evita cerinţa ca utilizatorul să prevadă el însuşi şi <div>-ul destinat barei de navigare. Dar partea într-adevăr incomodă, ţine de maniera prevăzută pentru a pune lucrurile în mişcare: la sfârşitul fişierului HTML care conţine structura indicată mai sus, trebuie înscris:

<script>
    ptprf = new Pager('torar', 10);
    ptprf.init();
    ptprf.showPageNav('ptprf', 'navorar');
    ptprf.showPage(1);
</script>

Adică, pentru fiecare tabel trebuie prevăzută câte o variabilă globală precum aici ptprf, iar aceasta este şi intern referită prin ptprf.showPageNav('ptprf', 'navorar')… Se reuşeşte astfel "legarea" barei de navigare de tabelul căruia îi este ataşată, încât să poată fi navigate în mod independent mai multe tablouri existente într-o aceeaşi pagină. Dar procedeul folosit pentru aceasta este chiar artificial.

Delimitări şi conturări

Există astăzi mai multe piese remarcabile, care angrenează (în javaScript) o multitudine de operaţii specifice tabelelor: jqGrid, DataTables, TableSort Pagination, etc.

Ne-a interesat însă o singură operaţie: paginarea unui tabel de dimensiune rezonabilă. Browserul primeşte (de la server) întregul tabel şi trebuie să arate o primă "pagină" conţinând un anumit număr de rânduri ale tabelului; de asemenea, trebuie să arate utilizatorului o "bară de navigare" prin intermediul căreia acesta să poată schimba pagina curentă (pentru tabelul respectiv).

Concepem bara de navigare astfel: /12 Prev Next Primul element este un <select> cu opţiuni prestabilite în mod rezonabil, pentru "numărul de rânduri" pe pagină. Urmează un <input> şi un <span>, permiţând specificarea uneia dintre paginile existente (pagina 3 / 12 pagini). Urmează desigur, nişte butoane <img> pentru trecere la pagina precedentă, respectiv la pagina următoare.

Este de presupus că această structură este suficient de intuitivă pentru utilizator (măcar după câteva încercări evidente), încât nu este nevoie să prevedem în bara respectivă şi texte explicative ("alege numărul de rânduri", sau "treci la următoarea pagină", etc.).

La acţionarea de către utilizator a unuia dintre elementele barei de navigare, trebuie realizată o aceeaşi operaţie: determină rândurile din tabel corespunzătoare noii pagini şi expune pagina respectivă; ideea tipică pentru aceasta constă în mascarea rândurilor din tabel care nu corespund paginii respective (browserul nu va afişa rândurile care au proprietatea display: none).

Dar aici intervin câteva aspecte care ţin de structura HTML concretă, a tabelului respectiv. Un <table> poate să includă sau nu un antet, iar acesta ar trebui menţinut pe fiecare pagină; rândurile care formează antetul nu trebuie mascate şi nici socotite în cadrul "numărului de rânduri" ale paginii. Acelaşi aspect apare şi în cazul existenţei unei secţiuni <tfoot> a tabelului - "subsolul" trebuie şi el păstrat pe fiecare pagină şi trebuie să fie independent de numărul de rânduri ale paginii.

Din fericire să zicem, putem evita toate complicaţiile angajând nu un tablou javaScript "al tuturor rândurilor" (cum face paging.js, scăzând apoi 1 pentru un presupus header de un singur rând), ci un tablou al rândurilor din secţiunea <tbody> (care este creată în DOM chiar şi dacă nu apare explicit în HTML-ul tabelului). Primul dintre exemplele construite mai jos vizează aspectul tocmai conturat.

Un al doilea aspect vizează tabelele imbricate: unele elemente <td> au drept conţinut elemente <table>. Dar rândurile sub-tabelelor n-ar trebui socotite în cadrul "numărului de rânduri" ale paginii tabelului principal; cu alte cuvinte, trebuie ignorate elementele <tbody> corespunzătoare sub-tabelelor. Mai mult, ar trebui avut în vedere că şi sub-tabelele ar putea necesita eventual, să fie şi ele "paginate".

Există şi alte cazuri, mai complicat de rezolvat faţă de cele două prezentate mai sus. De exemplu, cazul când avem un rând format de fapt din mai multe rânduri "parţiale": există pe un rând un <td rowspan="3">, ceea ce înseamnă că rândul respectiv împreună cu următoarele 3 rânduri ar trebui socotite ca fiind un singur rând…

Dar tot aşa de adevărat este că avem şi alte metode de "paginare". În fond, scopul este de a arăta tabelul încât el să nu ocupe prea mult loc pe ecran, iar aceasta se poate rezolva şi astfel: se ambalează tabelul (sau numai secţiunea <tbody> a sa) într-un <div> care are fixată corespunzător proprietatea height şi are overflow: auto (atunci, rândurile tabelului vor putea fi scroll-ate în acea diviziune).

Generarea de tabele HTML şi construcţia unor exemple

Pentru testări, punere la punct, experimente şi demonstraţii trebuie să imaginăm ceva metode şi tehnici pentru generarea în cadrul unui document a unui tabel abstract (nu datele interesează, ci structura şi eventual dimensiunea). Arătăm cum putem genera astfel de tabele folosind jQuery.

Dar nu ne propunem chiar sintetizarea unei funcţii generice, care primind un ID, numărul de rânduri şi indicaţii de includere a unor anumite secţiuni, să creeze tabelul HTML şi să îl înscrie în document la ID-ul indicat. Aici redăm doar metoda "brută": se creează obiectele necesare folosind $(element) şi $(obiect).append() şi corelăm generarea tabelului cu exemplificarea paginării.

Un tabel cu antet şi subsol

Secvenţa următoare generează un tabel HTML care are un antet - nedeclarat explicit prin <thead>, dar recunoscut ca atare fiindcă este la începutul tabelului şi conţine <th>, nu <td> - format dintr-un singur rând, are subsol constituit din două rânduri şi un <tbody> (nedeclarat explicit) cu 20 de rânduri de date.

$(function() {
    var antet = $('<tr><th>col-1</th><th>col-2</th></tr>');
    var subsol1 = $('<tr><th>sum-1</th><th>sum-2</th></tr>');
    var subsol2 = $('<tr><th colspan="2">—exemplu—</th></tr>');
    var subsol = $('<tfoot></tfoot>').append(subsol1).append(subsol2);

    var tabel = $('<table>').attr('border', '1').append(antet).append(subsol);

    var thtml = []; // va conţine rândurile de date (din <tbody>)
    for(var i = 1; i <= 20; i++)
        thtml.push('<tr><td>TD '+ i + '-1</td><td>TD '+ i + '-2</td></tr>');

    $(tabel).append(thtml.join('')); // alert($(tabel).html()); 

    $('#store-tabel').append(tabel); // înscrie tabelul în <div>-ul cu ID='store-tabel'

    $(tabel).pagtable({ perpag: 4 }); // asigură paginarea tabelului (4 rânduri/pagină)
});

Tabelul este construit în memorie (folosind intermediar tabloul de şiruri thtml[]) şi apoi este înscris în document drept conţinut al diviziunii cu atributul ID = 'store-tabel' (existentă mai jos, în această pagină HTML - a cărei sursă poate fi vizualizată din browser).

Pagina HTML de faţă a inclus la încărcarea sa în browser, fişierul pagtable.js care conţine funcţia pagtable() invocată în finalul secvenţei redate mai sus. Această funcţie montează bara de navigare şi asigură paginarea tabelului; ea permite ca opţiune iniţială parametrul perpag, setat aici cu valoarea 4 - încât cele 20 de rânduri de date constituie 5 pagini (şi iniţial se afişează pagina 1 dintre cele 5).

Schimbând într-un fel sau altul pagina curentă, se poate constata că antetul şi subsolul tabelului rămân fixe, pe fiecare pagină - tocmai ceea ce am vrut să testăm (sau acum, să demonstrăm).

Tabele imbricate

Rescriem pur şi simplu, secvenţa de generare anterioară, inserând acum şi generarea unui "sub-tabel" cu propria bară de navigare:

$(function() {
    var antet = $('<tr><th>col-1</th><th>col-2</th></tr>');
    var subsol1 = $('<tr><th>sum-1</th><th>sum-2</th></tr>');
    var subsol2 = $('<tr><th colspan="2">—exemplu—</th></tr>');
    var subsol = $('<tfoot></tfoot>').append(subsol1).append(subsol2);

    var tabel = $('<table>').attr('border', '1').append(antet).append(subsol);

    var thtml = [];
    for(var i = 1; i <= 20; i++)
        thtml.push('<tr><td>TD '+ i + '-1</td><td>TD '+ i + '-2</td></tr>');
          
    var subtabel = $('<table>'); // "sub-tabel", dar fără THEAD, TFOOT
    $(subtabel).append(thtml.join('').replace(/TD/g, 's.td'));
         
    $(tabel).append(thtml.slice(0,2).join('')); 
    $('<tr></tr>').append($('<td>TD-ul vecin<br>conţine un<br>(sub)tabel</td>'))
        .append(subtabel).appendTo(tabel); // inserează subtabelul în tabel
          
    $(tabel).append(thtml.slice(3).join('')); // adaugă rânduri tabelului principal
    $('#store-tabel-1').append(tabel); // înscrie tabelul în document

    $(subtabel).pagtable({perpag: 3})
        .css('border','1px solid blue')
        .next()  // vizează bara de navigare a sub-tabelului, aplicând nişte stiluri CSS
        .css({textAlign:'center', background:'#eee', margin:'0em'});
    
    $(tabel).pagtable() // corectează apariţia iniţial defectuoasă, a barei de navigare:
        .next()                  // vizează bara de navigare şi
        .find('a:last').click()  // simulează click pe "Next", apoi
        .prev().click();         // şi click pe "Prev"
});

$(tabel).pagtable() paginează tabelul principal cu câte 5 rânduri/pagină; în prima pagină, între aceste 5 rânduri se află şi rândul care conţine sub-tabelul. Cele două tabele pot fi navigate independent unul de celălalt, prin intermediul barelor de navigare asociate.

În final, s-a simulat click pe butoanele înainte/înnapoi ale barei asociate tabelului principal pentru că altfel, bara de navigare asociată sub-tabelului ar fi fost arătată iniţial nu în poziţia corectă, ci… alături de sub-tabel (în dreapta şi în afara tabelului principal).

Antetul şi subsolul tabelului principal râmân fixe, pe fiecare pagină; dar… sub-tabelul nu poate să aibă şi el, antet sau subsol (ci numai <tbody>) - paginarea ar fi defectuoasă…

Am constatat aici două "defecte". Primul a putut fi corectat "din exterior" (simulând click pe butoane ale barei de navigare); însă al doilea ("imposibilitatea" de a avea secţiuni de antet/subsol în sub-tablou) ar necesita probabil, ceva modificări chiar în functia pagtable() - ceea ce ignorăm aici, considerând că totuşi, aspectul este destul de particular…

Plugin jQuery pentru paginarea unui tabel HTML

Redăm în sfârşit, reconstrucţia pe care am făcut-o pentru scriptul 'paging.js' (şi care a fost folosită mai sus, în cele două exemplificări).

Variabilele bar1 şi bar2 de la început, reprezintă elementele barei de navigare; pentru a nu încărca inutil lista de opţiuni, am definit "hard" cele două atribute src şi ele trebuie eventual modificate de către utilizator, pentru a reflecta corect calea spre <img>-urile folosite pentru "Prev" şi "Next".
De asemenea, opţiunile din elementul <select> sunt fixate "rezonabil" 5|10|15|20, dar desigur că utilizatorul le poate schimba cum crede, pe copia proprie a scriptului.

(function($) {
    var bar1 = '<a href=""><img src="button_previous.gif" alt="Prev"/></a>' +
               '<a href=""><img src="button_next.gif" alt="Next"></a>';
    var bar2 = '<select><option value="5">5</option><option value="10">10</option>' +
               '<option value="15">15</option><option value="20">20</option></select>' +
               ' <input type="text" size="3" />';
    
    $.fn.pagtable = function(settings) {
        var options = {
            perpag: 5,       // număr implicit de rânduri/pagină
            navcss: 'navcss' // clasă CSS pentru elementele barei de navigare
        };
        $.extend(options, settings);

        var perpag = parseInt(options.perpag);
        var crtpag = 1; // pagina curentă
        
        var rows = $('tbody:first', this).children(':not(:has(th))');
        var pages = Math.ceil((rows.length) / perpag); // numărul de pagini

        var nav = $('<p class="' + options.navcss + '">' + bar2 + 
                    '<span>'+ pages +'</span> '+ bar1 +'</p>').insertAfter($(this));
        
        $(nav).find('a:first').click(function() { // pentru Prev (pagina precedentă)
            if(crtpag > 1) {
                crtpag --;
                showPage(crtpag);
            }
            return false; // evită propagarea nativă a 'click'-ului
        });            

        $(nav).find('a:last').click(function() { // pentru Next (pagina următoare)
            if(crtpag < pages) {
                crtpag ++;
                showPage(crtpag);
            }
            return false;
        });

        var gopag = $(nav).find('input:first'); // referă INPUT-ul pentru pagină

        $(gopag).keyup(function() { // când se tastează un număr de pagină
            var n = parseInt($(this).val()); // 'this' referă INPUT-ul (nu tabelul)
            if(n >= 1 && n <= pages) {
                crtpag = n;
                showPage(crtpag);
            }
        });            

        $(nav).find('select:first').change(function() { // numărul de rânduri/pagină
            perpag = parseInt($(this).val());
            var n = rows.length;
            if(perpag <= n) {
                pages = Math.ceil(n / perpag);
                crtpag = 1;
                $(gopag).next('span:first').text(pages);
                showPage(crtpag);
            }
            this.blur(); // previne schimbarea accidentală (click pe o tastă-săgeată)
        });
        
        function showPage(pag) { 
            var from = (pag - 1) * perpag,
                to = from + perpag - 1;
            for(var i = 0; i < rows.length; i++) // maschează rândurile dinafara paginii
                rows[i].style.display = (i < from || i > to) ? 'none' : '';
            $(gopag).val(pag); // actualizează INPUT-ul care indică pagina curentă
        };

        showPage(crtpag); // redă prima pagină şi bara de navigare
        return $(this);   // pentru "înlănţuire": $(tabel).pagtable().next().css(...); 
    };
})(jQuery);

Am văzut anterior că partea esenţială a mecanismului de paginare este selectarea rândurilor: cele care aparţin de antet/subsol trebuie excluse, pentru a evita mascarea lor (secţiunile respective trebuind să apară pe fiecare pagină a tabelului). Cum se vede mai sus în textul scriptului, noi am realizat aceasta folosind:

var rows = $('tbody:first', this).children(':not(:has(th))');

$('tbody', this) ar fi selectat toate secţiunile <tbody> (incluzând şi pe cele din sub-tablouri); deci am adoptat $('tbody:first', this), care selectează numai secţiunea <tbody> din tabelul "principal" (încât sub-tabloul dacă există, va apărea pe pagină ca un singur rând). În acelaşi timp, se ignoră astfel şi secţiunile <thead> şi <tfoot>, dacă acestea există (în mod explicit) în tabel.

Având nevoie de rândurile-fii şi nu de obiectul $('tbody:first', this), am continuat selectarea implicând .children(). Dar am ţinut cont de cazul destul de obişnuit, că tabelul ar avea un antet nedeclarat explicit prin <thead> (adică are la început un <tr> constituit din elemente <th>) şi am exclus rândurile respective folosind .children(':not(:has(th))') - ajungând astfel la selectorul final specificat mai sus. Dar, cum-necum, am constatat că pentru ca paginarea să fie corectă este necesar totuşi ca sub-tabelul să nu conţină antet/subsol…

Folosirea funcţiei pagtable()

Presupunând că s-a încărcat jQuery şi de asemenea, scriptul redat mai sus - atunci pagtable() se poate folosi (în cadrul unui element <script> din pagina HTML curentă) astfel:

     $('#id-tabel').pagtable();

pentru a arăta şi apoi a naviga pe pagini de câte 5 rânduri, tabelul HTML cu atributul de identificare ID = "id-tabel". Sau:

     $('#id-tabel').pagtable({ perpag: 7 });

pentru pagini de câte 7 rânduri.

Funcţii precum showPage() nu sunt accesibile "din afară"; atunci cum se poate arăta iniţial de exemplu pagina 3, în loc de prima? Soluţia constă în a accesa "din program" bara de navigare şi a seta value = 3 în elementul <input> al ei (dar în final, trebuie şi simulată tastarea valorii!):

     $('#id-tabel').pagtable({ perpag: 7 })
                   .next()   // vizează bara de navigare, adăugată după tabel
                   .find('input') //identifică elementul <input> din bară
                   .val(3)  // înscrie "value" = pagina de arătat iniţial
                   .keyup(); // acţionează handler-ul asociat <input>-ului

Poate complicat, dar este instructiv… Dacă într-adevăr ar fi nevoie de o asemenea funcţionalitate (arată întâi cutare pagină din tabel), atunci se poate extinde plugin-ul de mai sus cu o nouă funcţie conţinând secvenţa "complicată" de mai sus.

În sfârşit, utilizatorul poate implica o clasă CSS proprie pentru bara de navigare, folosind parametrul opţional navcss (care are valoarea implicită 'navcss'):

     $('#id-tabel').pagtable({ perpag: 7, navcss: 'myClassNav' });

De exemplu, noi folosim aceste definiţii CSS pentru barele de navigare asociate tabelelor noastre:

.navcss { margin-top:0.5em; }
.navcss, .navcss select, .navcss input { 
     font-family: monospace; font-weight: bold; font-size: 0.8em; 
}
.navcss img { border:2px solid #eee; vertical-align:top; }
.navcss input { width: 2em;}

Numele 'navcss' este înscris "hard" - vezi var options = { ... } - dar desigur, poate fi schimbat pe sursa scriptului. În orice caz, clasa definită pe parametrul navcss, dacă există este aplicată tuturor barelor de navigare asociate tabelelor.

Pe de altă parte, pentru vreun anumit tabel, bara de navigare se poate stila şi direct:

     $('#id-tabel').pagtable({ perpag: 7 })
                   .next()  // vizează bara de navigare
                   .css({textAlign:'center', background:'#eee', margin:'0em'});

(proprietăţi CSS precum text-align trebuie scrise în .css() sub forma textAlign)

pagtable.zip conţine pagtable.js, împreună cu o pagină de test (şi fişierele adiacente); am inclus de asemenea, fişierele paging.js şi paging.html referite la început.

vezi Cărţile mele (de programare)

docerpro | Prev | Next