by app-grid-factory.js with the same column
+ count as the final grid. Because it uses the same wrapper/table base
+ classes, the transition to real data is layout-stable (no jump).
+
+ - Initial load: skeleton sibling replaces the empty Grid.js container
+ (container is aria-busy while loading).
+ - Re-fetch (gridjs-is-updating after first load): a thin top progress
+ bar + muted tbody signals activity without destroying context.
+ - prefers-reduced-motion: shimmer disabled, static muted bars only.
+ ------------------------------------------------------------------- */
+
+ .app-grid-shell {
+ position: relative;
+ }
+
+ /* Hide Grid.js' built-in loading bar — we own loading UX now. */
.gridjs-loading-bar {
- background-color: var(--app-background-color);
- opacity: 0.65;
+ display: none;
}
- .gridjs-loading-bar:after {
+ /* While the skeleton is active, hide everything in the shell except the
+ skeleton itself. Grid.js mounts its own ``
+ INSIDE the user's container element (which has no class of its own), so
+ targeting `.gridjs-container` directly misses the mount wrapper. Using
+ `:not(.app-grid-skeleton)` catches whatever DOM structure the caller
+ provided as the mount point. `display: none` does not prevent Grid.js
+ from constructing DOM or starting its fetch — only paint. */
+ .app-grid-shell-loading > :not(.app-grid-skeleton) {
+ display: none !important;
+ }
+
+ .app-grid-skeleton {
+ /* Skeleton root mirrors the real grid's outer layout: a bordered card
+ wraps the table, and the footer (summary + pagination) sits outside
+ the card — identical to how `.gridjs-wrapper` + `.gridjs-footer` are
+ arranged. This keeps page-level layout stable across the transition. */
+ display: block;
+ }
+
+ .app-grid-skeleton-table {
+ border: var(--app-border-width) solid var(--app-table-border-color);
+ border-radius: calc(var(--app-border-radius) * 1.4);
+ background-color: var(--app-background-color);
+ overflow: hidden;
+ }
+
+ .app-grid-skeleton-sr {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ border: 0;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+ }
+
+ .app-grid-skeleton-table {
+ width: 100%;
+ border-collapse: collapse;
+ table-layout: auto;
+ }
+
+ .app-grid-skeleton-cell {
+ padding: var(--app-grid-cell-pad-y, 0.6rem) var(--app-grid-cell-pad-x, 0.9rem);
+ border-bottom: var(--app-border-width) solid var(--app-table-border-color);
+ vertical-align: middle;
+ text-align: left;
+ }
+
+ .app-grid-skeleton-cell-head {
+ background-color: color-mix(
+ in srgb,
+ var(--app-background-color) 94%,
+ var(--app-muted-border-color)
+ );
+ }
+
+ .app-grid-skeleton-row:last-child .app-grid-skeleton-cell {
+ border-bottom: 0;
+ }
+
+ .app-grid-skeleton-bar {
+ display: block;
+ height: 0.7rem;
+ border-radius: 999px;
+ /* Base track + travelling highlight; the highlight is what animates. */
+ background-color: color-mix(
+ in srgb,
+ var(--app-muted-border-color) 55%,
+ transparent
+ );
background-image: linear-gradient(
90deg,
- transparent,
- var(--app-primary-focus),
+ transparent 0%,
+ color-mix(in srgb, var(--app-primary-focus) 55%, transparent) 50%,
+ transparent 100%
+ );
+ background-size: 180px 100%;
+ background-repeat: no-repeat;
+ background-position: -180px 0;
+ animation: app-grid-skeleton-shimmer 1.4s linear infinite;
+ }
+
+ .app-grid-skeleton-cell-head .app-grid-skeleton-bar {
+ height: 0.55rem;
+ background-color: color-mix(
+ in srgb,
+ var(--app-muted-border-color) 70%,
transparent
);
}
- .gridjs-container.app-grid-loading-skeleton .gridjs-loading-bar {
- display: none;
+ /* Footer skeleton — visually matches the real `.gridjs-pagination`:
+ summary (results count) left-aligned, pager buttons right-aligned. */
+ .app-grid-skeleton-footer {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.65rem;
+ padding: 0.85rem 0 0;
}
- .gridjs-container.app-grid-loading-skeleton:not(.gridjs-has-loaded) .gridjs-wrapper::before {
- content: "";
- display: block;
- height: min(52vh, 520px);
- border-radius: inherit;
- background:
- linear-gradient(
- 90deg,
- transparent,
- color-mix(in srgb, var(--app-primary-focus) 50%, transparent),
- transparent
- ),
- repeating-linear-gradient(
- 180deg,
- color-mix(in srgb, var(--app-muted-border-color) 50%, transparent) 0,
- color-mix(in srgb, var(--app-muted-border-color) 50%, transparent) 14px,
- transparent 14px,
- transparent 44px
- );
- background-size: 260px 100%, 100% 100%;
- background-repeat: no-repeat, no-repeat;
+ .app-grid-skeleton-summary {
+ flex: 0 0 auto;
+ width: clamp(120px, 18%, 200px);
+ }
+
+ .app-grid-skeleton-summary .app-grid-skeleton-bar {
+ height: 0.55rem;
+ }
+
+ .app-grid-skeleton-pager {
+ margin-inline-start: auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ }
+
+ .app-grid-skeleton-pager-button {
+ display: inline-block;
+ min-width: 2rem;
+ height: 1.75rem;
+ border-radius: calc(var(--app-border-radius) * 0.9);
+ background-color: color-mix(
+ in srgb,
+ var(--app-muted-border-color) 55%,
+ transparent
+ );
+ background-image: linear-gradient(
+ 90deg,
+ transparent 0%,
+ color-mix(in srgb, var(--app-primary-focus) 55%, transparent) 50%,
+ transparent 100%
+ );
+ background-size: 120px 100%;
+ background-repeat: no-repeat;
+ background-position: -120px 0;
animation: app-grid-skeleton-shimmer 1.4s linear infinite;
}
- .gridjs-container.app-grid-loading-skeleton:not(.gridjs-has-loaded) .gridjs-table,
- .gridjs-container.app-grid-loading-skeleton:not(.gridjs-has-loaded) .gridjs-footer {
- display: none;
+ @media (max-width: 768px) {
+ .app-grid-skeleton-footer {
+ align-items: stretch;
+ }
+ .app-grid-skeleton-pager {
+ margin-inline-start: 0;
+ flex-wrap: wrap;
+ }
}
- .gridjs-container.app-grid-loading-skeleton.gridjs-is-updating .gridjs-message.gridjs-notfound {
- display: none;
+ @media (prefers-reduced-motion: reduce) {
+ .app-grid-skeleton-pager-button {
+ animation: none;
+ background-image: none;
+ }
}
- .gridjs-container.app-grid-loading-skeleton.gridjs-is-updating .gridjs-tbody .gridjs-td {
+ /* Re-fetch feedback: thin top progress bar + faded tbody so users see
+ activity without losing the current data context (GitHub pattern). */
+ .gridjs-container.gridjs-is-updating.gridjs-has-loaded .gridjs-wrapper {
position: relative;
- color: transparent;
- pointer-events: none;
}
- .gridjs-container.app-grid-loading-skeleton.gridjs-is-updating .gridjs-tbody .gridjs-td::after {
+ .gridjs-container.gridjs-is-updating.gridjs-has-loaded .gridjs-wrapper::before {
content: "";
position: absolute;
- inset: 30% 0.7rem;
- border-radius: 999px;
- background:
- linear-gradient(
- 90deg,
- color-mix(in srgb, var(--app-muted-border-color) 48%, transparent),
- color-mix(in srgb, var(--app-primary-focus) 42%, transparent),
- color-mix(in srgb, var(--app-muted-border-color) 48%, transparent)
- );
- background-size: 240px 100%;
- animation: app-grid-skeleton-shimmer 1.5s ease-in-out infinite;
+ inset: 0 0 auto 0;
+ height: 2px;
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ var(--app-primary),
+ transparent
+ );
+ background-size: 40% 100%;
+ background-repeat: no-repeat;
+ animation: app-grid-progress-slide 1.1s ease-in-out infinite;
+ z-index: 4;
}
- .gridjs-container.app-grid-loading-skeleton.gridjs-is-updating .gridjs-tbody .gridjs-td > * {
- visibility: hidden;
+ .gridjs-container.gridjs-is-updating.gridjs-has-loaded .gridjs-tbody {
+ opacity: 0.55;
+ transition: opacity 0.15s ease;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ .app-grid-skeleton-bar,
+ .gridjs-container.gridjs-is-updating.gridjs-has-loaded .gridjs-wrapper::before {
+ animation: none;
+ }
+ .app-grid-skeleton-bar {
+ background-image: none;
+ }
}
.gridjs-message {
@@ -528,15 +723,25 @@
.gridjs-pagination .gridjs-pages {
max-width: 100%;
flex-wrap: wrap;
+ margin-inline-start: 0;
}
}
@keyframes app-grid-skeleton-shimmer {
0% {
- background-position-x: -240px, 0;
+ background-position: -180px 0;
}
100% {
- background-position-x: calc(100% + 240px), 0;
+ background-position: calc(100% + 180px) 0;
+ }
+ }
+
+ @keyframes app-grid-progress-slide {
+ 0% {
+ background-position: -40% 0;
+ }
+ 100% {
+ background-position: 140% 0;
}
}
}
diff --git a/web/js/core/app-grid-factory.js b/web/js/core/app-grid-factory.js
index 403a685..362cb37 100644
--- a/web/js/core/app-grid-factory.js
+++ b/web/js/core/app-grid-factory.js
@@ -135,7 +135,11 @@ export function createServerGrid(options) {
}
const uiConfig = ui && typeof ui === 'object' ? ui : {};
- const stickyHeaderEnabled = uiConfig.stickyHeader !== false;
+ // Sticky header is opt-in: enabling it forces a fixed-height scroll container
+ // inside the wrapper (Grid.js requirement for `fixedHeader`). On short result
+ // sets that container fills empty space and makes rows visually expand. The
+ // Stripe-like default scrolls on the page, not in the table.
+ const stickyHeaderEnabled = uiConfig.stickyHeader === true;
const gridHeight = typeof uiConfig.height === 'string' && uiConfig.height.trim() !== ''
? uiConfig.height.trim()
: DEFAULT_GRID_HEIGHT;
@@ -297,6 +301,7 @@ export function createServerGrid(options) {
|| 'Press Enter to open row'
).trim() || 'Press Enter to open row';
const gridRetryLabel = String(language?.errorRetry || 'Retry').trim() || 'Retry';
+ const gridLoadingLabel = String(language?.loading || 'Loading').trim() || 'Loading';
const resolveRowOpenUrl = (rowData, rowIndex = -1) => {
if (!rowInteractionEnabled) {
return '';
@@ -721,6 +726,106 @@ export function createServerGrid(options) {
plugins
});
+ // DOM-based skeleton (Stripe/GitHub-style): render a real table with the
+ // same column structure as the final grid, so the transition to real data
+ // is layout-stable and never flickers. We wrap the container in a shell so
+ // the skeleton can live as a sibling — Grid.js' render() controls the
+ // container's subtree and would wipe any skeleton placed inside it.
+ const shellEl = document.createElement('div');
+ shellEl.className = 'app-grid-shell';
+ containerEl.parentNode?.insertBefore(shellEl, containerEl);
+ shellEl.appendChild(containerEl);
+
+ const skeletonEnabled = loadingMode === 'skeleton';
+ const SKELETON_ROW_COUNT = 15;
+ const SKELETON_BAR_WIDTHS = ['72%', '58%', '64%', '44%', '70%', '52%', '60%', '48%'];
+ const SKELETON_PAGINATION_BUTTONS = 5;
+
+ const visibleSkeletonColumns = normalizedColumns.filter((col) => !col?.hidden && !col?.plugin);
+
+ const buildSkeletonBar = (width) => {
+ const bar = document.createElement('span');
+ bar.className = 'app-grid-skeleton-bar';
+ bar.style.width = width;
+ return bar;
+ };
+
+ const buildSkeleton = () => {
+ const root = document.createElement('div');
+ root.className = 'app-grid-skeleton';
+ root.setAttribute('role', 'status');
+ root.setAttribute('aria-label', gridLoadingLabel);
+ root.setAttribute('aria-live', 'polite');
+
+ const srLabel = document.createElement('span');
+ srLabel.className = 'app-grid-skeleton-sr';
+ srLabel.textContent = gridLoadingLabel;
+ root.appendChild(srLabel);
+
+ const table = document.createElement('table');
+ table.className = 'app-grid-skeleton-table';
+ table.setAttribute('aria-hidden', 'true');
+
+ const thead = document.createElement('thead');
+ const headRow = document.createElement('tr');
+ visibleSkeletonColumns.forEach(() => {
+ const th = document.createElement('th');
+ th.className = 'app-grid-skeleton-cell app-grid-skeleton-cell-head';
+ th.appendChild(buildSkeletonBar('55%'));
+ headRow.appendChild(th);
+ });
+ thead.appendChild(headRow);
+ table.appendChild(thead);
+
+ const tbody = document.createElement('tbody');
+ for (let rowIdx = 0; rowIdx < SKELETON_ROW_COUNT; rowIdx += 1) {
+ const tr = document.createElement('tr');
+ tr.className = 'app-grid-skeleton-row';
+ visibleSkeletonColumns.forEach((_col, colIdx) => {
+ const td = document.createElement('td');
+ td.className = 'app-grid-skeleton-cell';
+ const width = SKELETON_BAR_WIDTHS[(rowIdx + colIdx) % SKELETON_BAR_WIDTHS.length];
+ td.appendChild(buildSkeletonBar(width));
+ tr.appendChild(td);
+ });
+ tbody.appendChild(tr);
+ }
+ table.appendChild(tbody);
+ root.appendChild(table);
+
+ // Footer skeleton: mirrors the real pagination layout — summary (results
+ // count) on the left, pagination buttons on the right — so the transition
+ // doesn't shift the surrounding page layout.
+ const footer = document.createElement('div');
+ footer.className = 'app-grid-skeleton-footer';
+ footer.setAttribute('aria-hidden', 'true');
+
+ const summary = document.createElement('div');
+ summary.className = 'app-grid-skeleton-summary';
+ summary.appendChild(buildSkeletonBar('100%'));
+ footer.appendChild(summary);
+
+ const pager = document.createElement('div');
+ pager.className = 'app-grid-skeleton-pager';
+ for (let i = 0; i < SKELETON_PAGINATION_BUTTONS; i += 1) {
+ const btn = document.createElement('span');
+ btn.className = 'app-grid-skeleton-pager-button';
+ pager.appendChild(btn);
+ }
+ footer.appendChild(pager);
+
+ root.appendChild(footer);
+ return root;
+ };
+
+ let skeletonEl = null;
+ if (skeletonEnabled && visibleSkeletonColumns.length > 0) {
+ skeletonEl = buildSkeleton();
+ shellEl.insertBefore(skeletonEl, containerEl);
+ shellEl.classList.add('app-grid-shell-loading');
+ containerEl.setAttribute('aria-busy', 'true');
+ }
+
grid.render(containerEl);
setTimeout(() => {
initSelectAll();
@@ -732,6 +837,12 @@ export function createServerGrid(options) {
if (hasLoadedOnce) {return;}
hasLoadedOnce = true;
containerEl.classList.add('gridjs-has-loaded');
+ containerEl.removeAttribute('aria-busy');
+ shellEl.classList.remove('app-grid-shell-loading');
+ if (skeletonEl) {
+ skeletonEl.remove();
+ skeletonEl = null;
+ }
};
const setUpdating = () => containerEl.classList.add('gridjs-is-updating');
const clearUpdating = () => containerEl.classList.remove('gridjs-is-updating');