Skip to content

Commit 29e26fc

Browse files
authored
Merge pull request #5880 from Laravel-Backpack/fix-datatable-stiky-header
Fixes stiky header "bumping" in datatables
2 parents 8c9e460 + dc7b141 commit 29e26fc

File tree

4 files changed

+284
-4
lines changed

4 files changed

+284
-4
lines changed

src/app/View/Components/Datatable.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public function __construct(
2121
private bool $modifiesUrl = false,
2222
private ?\Closure $setup = null,
2323
private ?string $name = null,
24+
private ?bool $useFixedHeader = null,
2425
) {
2526
// Set active controller for proper context
2627
CrudManager::setActiveController($controller);
@@ -85,11 +86,14 @@ private function generateTableId(): string
8586

8687
public function render()
8788
{
89+
$useFixedHeader = $this->useFixedHeader ?? $this->crud->getOperationSetting('useFixedHeader') ?? true;
90+
8891
return view('crud::components.datatable.datatable', [
8992
'crud' => $this->crud,
9093
'modifiesUrl' => $this->modifiesUrl,
9194
'tableId' => $this->tableId,
9295
'datatablesUrl' => url($this->crud->get('list.datatablesUrl')),
96+
'useFixedHeader' => $useFixedHeader,
9397
]);
9498
}
9599
}

src/config/backpack/operations/list.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@
2525
// how much time should the system wait before triggering the search function after the user stops typing?
2626
'searchDelay' => 400,
2727

28+
// should we use a fixed header for the datatables?
29+
'useFixedHeader' => true,
30+
2831
// the time the table will be persisted in minutes
2932
// after this the table info is cleared from localStorage.
3033
// use false to never force localStorage clear. (default)
3134
// keep in mind: User can clear their localStorage whenever they want.
32-
3335
'persistentTableDuration' => false,
3436

3537
// How many items should be shown by default by the Datatable?

src/resources/views/crud/components/datatable/datatable.blade.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
@php
2-
// Define the table ID - use the provided tableId or default to 'crudTable'
3-
$tableId = $tableId ?? 'crudTable';
2+
// Define the table ID - use the provided tableId or default to 'crudTable'
3+
$tableId = $tableId ?? 'crudTable';
4+
$fixedHeader = $useFixedHeader ?? $crud->getOperationSetting('useFixedHeader') ?? true;
45
@endphp
56
<section class="header-operation datatable-header animated fadeIn d-flex mb-2 align-items-baseline d-print-none" bp-section="page-header">
67
<h1 class="text-capitalize mb-0" bp-section="page-heading">{!! $crud->getHeading() ?? $crud->entity_name_plural !!}</h1>
@@ -36,6 +37,7 @@
3637
<table
3738
id="{{ $tableId }}"
3839
class="{{ backpack_theme_config('classes.table') ?? 'table table-striped table-hover nowrap rounded card-table table-vcenter card d-table shadow-xs border-xs' }} crud-table"
40+
data-use-fixed-header="{{ $fixedHeader ? 'true' : 'false' }}"
3941
data-responsive-table="{{ (int) $crud->getOperationSetting('responsiveTable') }}"
4042
data-has-details-row="{{ (int) $crud->getOperationSetting('detailsRow') }}"
4143
data-has-bulk-actions="{{ (int) $crud->getOperationSetting('bulkActions') }}"

src/resources/views/crud/components/datatable/datatable_logic.blade.php

Lines changed: 273 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,32 @@
1919
@endpush
2020

2121
<script>
22+
/* eslint-disable */
23+
(function($) {
24+
if (!$ || !$.fn || !$.fn.dataTable || !$.fn.dataTable.FixedHeader) {
25+
return;
26+
}
27+
28+
const proto = $.fn.dataTable.FixedHeader.prototype;
29+
if (!proto || proto._backpackNoBelowPatchApplied) {
30+
return;
31+
}
32+
33+
const originalModeChange = proto._modeChange;
34+
proto._modeChange = function(mode, type) {
35+
const args = Array.prototype.slice.call(arguments);
36+
if (type === 'header' && args[0] === 'below' && this && this.c && this.c.headerOffset > 0) {
37+
args[0] = 'in-place';
38+
}
39+
40+
const result = originalModeChange.apply(this, args);
41+
42+
return result;
43+
};
44+
45+
proto._backpackNoBelowPatchApplied = true;
46+
})(window.jQuery);
47+
2248
// Store the alerts in localStorage for this page
2349
let $oldAlerts = JSON.parse(localStorage.getItem('backpack_alerts'))
2450
? JSON.parse(localStorage.getItem('backpack_alerts')) : {};
@@ -198,6 +224,12 @@ functionsToRunOnDataTablesDrawEvent: [],
198224
config.lineButtonsAsDropdownMinimum = parseInt(tableElement.getAttribute('data-line-buttons-as-dropdown-minimum')) ?? 3;
199225
config.lineButtonsAsDropdownShowBeforeDropdown = parseInt(tableElement.getAttribute('data-line-buttons-as-dropdown-show-before-dropdown')) ?? 1;
200226
config.responsiveTable = tableElement.getAttribute('data-responsive-table') === 'true' || tableElement.getAttribute('data-responsive-table') === '1';
227+
const useFixedHeaderAttr = tableElement.getAttribute('data-use-fixed-header');
228+
if (useFixedHeaderAttr === null || useFixedHeaderAttr === '') {
229+
config.useFixedHeader = config.responsiveTable;
230+
} else {
231+
config.useFixedHeader = useFixedHeaderAttr.toLowerCase() === 'true';
232+
}
201233
config.exportButtons = tableElement.getAttribute('data-has-export-buttons') === 'true';
202234
// Apply any custom config
203235
if (customConfig && Object.keys(customConfig).length > 0) {
@@ -261,10 +293,14 @@ functionsToRunOnDataTablesDrawEvent: [],
261293
}
262294
263295
// Create DataTable configuration
296+
const initialFixedHeaderOffset = calculateStickyHeaderOffset(tableElement);
264297
const dataTableConfig = {
265298
bInfo: config.showEntryCount,
266299
responsive: config.responsiveTable,
267-
fixedHeader: config.responsiveTable,
300+
fixedHeader: config.useFixedHeader ? {
301+
header: true,
302+
headerOffset: initialFixedHeaderOffset
303+
} : false,
268304
scrollX: !config.responsiveTable,
269305
autoWidth: false,
270306
processing: true,
@@ -877,6 +913,242 @@ function resizeCrudTableColumnWidths() {
877913
resizeCrudTableColumnWidths();
878914
});
879915
}
916+
917+
registerFixedHeaderListeners(tableId, config);
918+
}
919+
920+
function resolveFixedHeaderOffset(fixedHeader, explicitOffset) {
921+
if (typeof explicitOffset === 'number') {
922+
return explicitOffset;
923+
}
924+
925+
if (!fixedHeader) {
926+
return 0;
927+
}
928+
929+
if (typeof fixedHeader.headerOffset === 'function') {
930+
const value = fixedHeader.headerOffset();
931+
if (typeof value === 'number') {
932+
return value;
933+
}
934+
}
935+
936+
if (fixedHeader.c && typeof fixedHeader.c.headerOffset === 'number') {
937+
return fixedHeader.c.headerOffset;
938+
}
939+
940+
return 0;
941+
}
942+
943+
function measureFixedHeaderHeight(fixedHeader, headerElement) {
944+
const storedHeight = fixedHeader && fixedHeader.s && typeof fixedHeader.s.headerHeight === 'number'
945+
? Math.max(0, Math.round(fixedHeader.s.headerHeight))
946+
: 0;
947+
948+
if (storedHeight > 0) {
949+
return storedHeight;
950+
}
951+
952+
if (headerElement) {
953+
const rectHeight = Math.max(0, Math.round(headerElement.getBoundingClientRect().height));
954+
if (rectHeight > 0) {
955+
return rectHeight;
956+
}
957+
958+
const offsetHeight = Math.max(0, Math.round(headerElement.offsetHeight || 0));
959+
if (offsetHeight > 0) {
960+
return offsetHeight;
961+
}
962+
}
963+
964+
return 56;
965+
}
966+
967+
function deriveFixedHeaderMargins(headerHeight) {
968+
const enableMargin = Math.max(10, headerHeight ? Math.round(Math.max(14, headerHeight * 0.35)) : 28);
969+
const disableMargin = Math.max(enableMargin + 14, headerHeight ? Math.round(Math.max(24, headerHeight * 0.6)) : 44);
970+
return { enableMargin, disableMargin };
971+
}
972+
973+
function registerFixedHeaderListeners(tableId, config) {
974+
if (!config.useFixedHeader || config.fixedHeaderListenersRegistered) {
975+
return;
976+
}
977+
978+
const tableElement = document.getElementById(tableId);
979+
const apiInstance = window.crud.tables[tableId];
980+
const fixedHeader = apiInstance && apiInstance.fixedHeader;
981+
982+
if (!tableElement || !fixedHeader || typeof fixedHeader.headerOffset !== 'function' || typeof fixedHeader.enabled !== 'function') {
983+
return;
984+
}
985+
986+
const headerElement = tableElement.querySelector('thead');
987+
const state = {
988+
timer: null,
989+
lastOffset: null,
990+
lastEnabled: null,
991+
listeners: []
992+
};
993+
994+
const ensureActivation = (explicitOffset) => {
995+
const offsetValue = resolveFixedHeaderOffset(fixedHeader, explicitOffset);
996+
const rect = tableElement.getBoundingClientRect();
997+
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
998+
const currentlyEnabled = fixedHeader.enabled();
999+
const headerHeight = measureFixedHeaderHeight(fixedHeader, headerElement);
1000+
const { enableMargin, disableMargin } = deriveFixedHeaderMargins(headerHeight);
1001+
1002+
const withinViewport = rect.top < viewportHeight - 1 && rect.bottom > offsetValue + 1;
1003+
if (!withinViewport) {
1004+
if (currentlyEnabled) {
1005+
fixedHeader.disable();
1006+
}
1007+
return false;
1008+
}
1009+
1010+
const headerBottom = rect.top + headerHeight;
1011+
const clearanceThreshold = currentlyEnabled ? offsetValue + disableMargin : offsetValue - enableMargin;
1012+
const shouldEnable = headerBottom <= clearanceThreshold;
1013+
1014+
if (shouldEnable === currentlyEnabled) {
1015+
return shouldEnable;
1016+
}
1017+
1018+
if (shouldEnable) {
1019+
fixedHeader.enable(true);
1020+
} else {
1021+
fixedHeader.disable();
1022+
}
1023+
1024+
return shouldEnable;
1025+
};
1026+
1027+
const recalculate = (reason) => {
1028+
const offset = calculateStickyHeaderOffset(tableElement);
1029+
const enabled = ensureActivation(offset);
1030+
const offsetChanged = typeof state.lastOffset !== 'number' || state.lastOffset !== offset;
1031+
const enabledChanged = typeof state.lastEnabled !== 'boolean' || state.lastEnabled !== enabled;
1032+
1033+
if (offsetChanged) {
1034+
fixedHeader.headerOffset(offset);
1035+
}
1036+
1037+
if (enabled && (offsetChanged || enabledChanged || /(?:dt:|window:resize|orientationchange)/.test(reason || ''))) {
1038+
if (typeof fixedHeader.adjust === 'function') {
1039+
fixedHeader.adjust();
1040+
}
1041+
}
1042+
1043+
state.lastOffset = offset;
1044+
state.lastEnabled = enabled;
1045+
};
1046+
1047+
const scheduleRecalculation = (reason) => {
1048+
if (state.timer) {
1049+
return;
1050+
}
1051+
1052+
state.timer = setTimeout(() => {
1053+
state.timer = null;
1054+
recalculate(reason || 'timer');
1055+
}, 75);
1056+
};
1057+
1058+
const addListener = (target, eventName, handler) => {
1059+
if (!target || !target.addEventListener) {
1060+
return;
1061+
}
1062+
target.addEventListener(eventName, handler, false);
1063+
state.listeners.push(() => target.removeEventListener(eventName, handler, false));
1064+
};
1065+
1066+
recalculate('initial');
1067+
setTimeout(() => recalculate('delayed-initial'), 150);
1068+
1069+
addListener(window, 'resize', () => scheduleRecalculation('window:resize'));
1070+
addListener(window, 'orientationchange', () => scheduleRecalculation('window:orientationchange'));
1071+
addListener(window, 'scroll', () => scheduleRecalculation('window:scroll'));
1072+
1073+
const $table = $(`#${tableId}`);
1074+
$table.on('column-visibility.dt.fixedHeader length.dt.fixedHeader responsive-resize.fixedHeader draw.dt.fixedHeader', function(evt) {
1075+
const eventLabel = evt && evt.type ? 'dt:' + evt.type : 'dt:unknown';
1076+
scheduleRecalculation(eventLabel);
1077+
});
1078+
1079+
$table.on('destroy.dt.fixedHeader', function() {
1080+
if (state.timer) {
1081+
clearTimeout(state.timer);
1082+
state.timer = null;
1083+
}
1084+
1085+
state.listeners.forEach(function(cleanup) {
1086+
cleanup();
1087+
});
1088+
state.listeners.length = 0;
1089+
1090+
$table.off('.fixedHeader');
1091+
config.fixedHeaderListenersRegistered = false;
1092+
});
1093+
1094+
config.fixedHeaderListenersRegistered = true;
1095+
}
1096+
1097+
function calculateStickyHeaderOffset(tableElement) {
1098+
if (!tableElement || tableElement.closest('.modal')) {
1099+
return 0;
1100+
}
1101+
1102+
if (typeof document.elementsFromPoint !== 'function') {
1103+
return 0;
1104+
}
1105+
1106+
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
1107+
const sampleX = Math.max(0, Math.round(viewportWidth / 2));
1108+
const maxScanDepth = Math.min(400, Math.max(200, (window.innerHeight || 0) / 2));
1109+
const seenElements = new Set();
1110+
let offset = 0;
1111+
1112+
for (let y = 0; y <= maxScanDepth; y += 8) {
1113+
const elements = document.elementsFromPoint(sampleX, y) || [];
1114+
1115+
elements.forEach((element) => {
1116+
if (!element || seenElements.has(element)) {
1117+
return;
1118+
}
1119+
1120+
seenElements.add(element);
1121+
1122+
if (element.closest('.dtfh-floatingparent')) {
1123+
return;
1124+
}
1125+
1126+
const computedStyle = window.getComputedStyle(element);
1127+
if (computedStyle.position !== 'sticky' && computedStyle.position !== 'fixed') {
1128+
return;
1129+
}
1130+
1131+
const rect = element.getBoundingClientRect();
1132+
if (rect.bottom <= 0) {
1133+
return;
1134+
}
1135+
1136+
const topValue = parseFloat(computedStyle.top) || 0;
1137+
if (topValue > y + 2) {
1138+
return;
1139+
}
1140+
1141+
offset = Math.max(offset, rect.bottom);
1142+
});
1143+
1144+
if (offset > 0 && y > offset) {
1145+
break;
1146+
}
1147+
}
1148+
1149+
const finalOffset = Math.max(0, Math.round(offset));
1150+
1151+
return finalOffset;
8801152
}
8811153
8821154
// Support for multiple tables with filters

0 commit comments

Comments
 (0)