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ègle | Détail |
|---|---|
| Wrapper | card shadow-sm + card-body p-0 |
| En-têtes | thead.table-dark toujours |
| Classes table | table table-hover align-middle mb-0 |
| Données primaires | .fw-semibold |
| Données secondaires | .text-muted small |
| Colonne Actions | text-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