Aller au contenu principal

Tableaux

Structure de base

<div class="card shadow-sm">
<div class="card-body p-0">

{{-- Barre de contrôles --}}
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom">
<input type="search" id="table-filter" class="form-control form-control-sm w-auto"
placeholder="Filtrer…" oninput="tableFilter(this.value)">
<button class="btn btn-outline-secondary btn-sm ms-auto" onclick="exportCsv()">
<i class="bi bi-download me-1"></i>CSV
</button>
</div>

<table class="table table-hover align-middle mb-0" id="main-table">
<thead class="table-dark">
<tr>
<th class="sortable" data-col="0">
Nom <span class="sort-icon text-secondary opacity-50">↕</span>
</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@forelse($items as $item)
<tr class="table-row-clickable" data-id="{{ $item->id }}" style="cursor:pointer">
<td class="fw-semibold">{{ $item->name }}</td>
<td class="text-end" onclick="event.stopPropagation()">
{{-- boutons d'action --}}
</td>
</tr>
@empty
<tr>
<td colspan="2" class="text-center text-muted py-5">
<i class="bi bi-inbox fs-2 d-block mb-2 text-secondary"></i>
Aucun élément enregistré.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>

Règles

RègleDétail
Wrappercard shadow-sm + card-body p-0
En-têtesthead.table-dark toujours
Classes tabletable table-hover align-middle mb-0
Données primaires.fw-semibold
Données secondaires.text-muted small
Colonne Actionstext-end, onclick="event.stopPropagation()"

Colonnes triables

:::info Règle obligatoire Toutes les colonnes de tous les tableaux doivent être triables par clic sur l'en-tête. :::

  • Neutre : (opacité réduite)
  • Tri croissant :
  • Tri décroissant :
(function () {
const table = document.getElementById('main-table');
if (!table) return;

let sortCol = -1, sortAsc = true;

table.querySelectorAll('thead th.sortable').forEach((th, idx) => {
th.style.cursor = 'pointer';
th.addEventListener('click', () => {
sortAsc = sortCol === idx ? !sortAsc : true;
sortCol = idx;
sortTable(table, idx, sortAsc);
updateSortIcons(table, idx, sortAsc);
});
});

function sortTable(tbl, col, asc) {
const tbody = tbl.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr:not(.no-sort)'));
rows.sort((a, b) => {
const va = a.cells[col]?.dataset.sort ?? a.cells[col]?.textContent.trim() ?? '';
const vb = b.cells[col]?.dataset.sort ?? b.cells[col]?.textContent.trim() ?? '';
const diff = va.localeCompare(vb, 'fr', { numeric: true, sensitivity: 'base' });
return asc ? diff : -diff;
});
rows.forEach(r => tbody.appendChild(r));
}

function updateSortIcons(tbl, activeCol, asc) {
tbl.querySelectorAll('thead th.sortable').forEach((th, idx) => {
const icon = th.querySelector('.sort-icon');
if (!icon) return;
if (idx === activeCol) {
icon.textContent = asc ? '↑' : '↓';
icon.classList.remove('opacity-50', 'text-secondary');
} else {
icon.textContent = '↕';
icon.classList.add('opacity-50', 'text-secondary');
}
});
}
})();

Tri numérique : utiliser data-sort="valeur_numerique" sur les <td> pour trier par valeur.


Filtre texte

function tableFilter(query) {
const q = query.toLowerCase().trim();
document.querySelectorAll('#main-table tbody tr').forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = (!q || text.includes(q)) ? '' : 'none';
});
}

Export CSV

function exportCsv() {
const table = document.getElementById('main-table');
const rows = Array.from(table.querySelectorAll('tr')).filter(r => r.style.display !== 'none');
const cols = table.querySelectorAll('thead th');
const lastHeader = cols[cols.length - 1]?.textContent.trim();
const skipLast = lastHeader === 'Actions' || lastHeader === '';

const csv = rows.map(row => {
const cells = Array.from(row.querySelectorAll('th, td'));
if (skipLast) cells.pop();
return cells.map(c => '"' + (c.dataset.export ?? c.textContent.trim()).replace(/"/g, '""') + '"').join(',');
}).join('\n');

const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'export.csv';
link.click();
}

Règles export :

  • BOM UTF-8 pour compatibilité Excel
  • Exclure la colonne Actions
  • Respecter le filtre actif (lignes visibles uniquement)
  • Utiliser data-export="valeur_brute" sur les <td> quand l'affichage diffère de la donnée brute

Lignes cliquables et modale Détail

:::info Règle obligatoire Toutes les lignes de tableau doivent être cliquables et ouvrir une modale Détail. :::

document.querySelectorAll('.table-row-clickable').forEach(row => {
row.addEventListener('click', () => openDetailModal(row.dataset.id));
});

Structure modale Détail

<div class="modal fade" id="modal-detail" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-info-circle me-2"></i>Détail</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="modal-detail-body">
{{-- Contenu chargé dynamiquement --}}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Fermer</button>
@can('update', $item)
<button type="button" class="btn btn-primary" id="modal-detail-save">
<i class="bi bi-check-lg me-1"></i>Enregistrer
</button>
@endcan
</div>
</div>
</div>
</div>

L'endpoint de chargement suit la convention : GET /module/resource/{id}/detail