diff --git a/web/css/vendor-overrides/gridjs.css b/web/css/vendor-overrides/gridjs.css index 68c48f0..f1bbfd6 100644 --- a/web/css/vendor-overrides/gridjs.css +++ b/web/css/vendor-overrides/gridjs.css @@ -1,7 +1,11 @@ @layer vendor-overrides { .gridjs-container { - --app-grid-cell-pad-y: calc(var(--app-spacing) * 0.55); - --app-grid-cell-pad-x: var(--app-spacing); + /* Single source of truth for row rhythm — padding + line-height drive height. + We do NOT set fixed `height` on th/td: that conflicts with variable content + (avatars, badges, buttons) and with Grid.js' fixedHeader scroll container. */ + --app-grid-cell-pad-y: 0.6rem; + --app-grid-cell-pad-x: 0.9rem; + --app-grid-row-line-height: 1.45; --app-grid-row-hover: color-mix( in srgb, var(--app-primary) 4%, @@ -28,13 +32,13 @@ } .gridjs-container.app-grid-density-sm { - --app-grid-cell-pad-y: calc(var(--app-spacing) * 0.45); - --app-grid-cell-pad-x: calc(var(--app-spacing) * 0.85); + --app-grid-cell-pad-y: 0.45rem; + --app-grid-cell-pad-x: 0.75rem; } .gridjs-container.app-grid-density-lg { - --app-grid-cell-pad-y: calc(var(--app-spacing) * 0.7); - --app-grid-cell-pad-x: calc(var(--app-spacing) * 1.15); + --app-grid-cell-pad-y: 0.85rem; + --app-grid-cell-pad-x: 1.05rem; } .gridjs-wrapper { @@ -43,8 +47,10 @@ border-radius: calc(var(--app-border-radius) * 1.4); box-shadow: none; background-color: var(--app-background-color); + /* Horizontal scroll for wide tables; vertical scroll is managed by Grid.js + (inline `height` / `max-height`) when fixedHeader is active. Do NOT clamp + overflow-y here or the sticky header breaks and rows get clipped. */ overflow-x: auto; - overflow-y: hidden; } .gridjs-container.gridjs-has-error .gridjs-wrapper { @@ -77,16 +83,39 @@ ); color: var(--app-color); font-weight: 600; - line-height: 1.25; - height: 2.55rem; + line-height: var(--app-grid-row-line-height); text-align: left; text-align: start; white-space: nowrap; + vertical-align: middle; } - /* Match content line-height to sort button height (24px) for uniform th height */ + /* Vendor uses floats (`.gridjs-th-content` float:left + `.gridjs-sort` + float:right) which break vertical centering: the 24px sort icon and the + text label land at different baselines. Replace floats with inline-block + + vertical-align:middle so both share the same cross-axis center. Keep + as table-cell (no display:flex) to preserve the browser's table + width-distribution algorithm. */ th.gridjs-th .gridjs-th-content { - line-height: 24px; + float: none; + display: inline-block; + vertical-align: middle; + width: auto; + max-width: calc(100% - 22px); + line-height: inherit; + overflow: hidden; + text-overflow: ellipsis; + } + + th.gridjs-th:not(.gridjs-th-sort) .gridjs-th-content { + max-width: 100%; + } + + th.gridjs-th button.gridjs-sort { + float: none; + display: inline-block; + vertical-align: middle; + margin-inline-start: 0.35rem; } .gridjs-wrapper .gridjs-th-fixed { @@ -112,8 +141,7 @@ background-color: var(--app-background-color); color: var(--app-color); font-weight: var(--app-font-weight); - line-height: 1.35; - height: 2.75rem; + line-height: var(--app-grid-row-line-height); white-space: nowrap; text-align: left; text-align: start; @@ -185,7 +213,10 @@ display: flex; flex-wrap: wrap; align-items: center; - justify-content: space-between; + /* Robust right-alignment regardless of summary presence: summary sits left, + pages pushed right by auto margin. space-between breaks when summary is + absent (single flex-child collapses visually). */ + justify-content: flex-start; gap: 0.65rem; color: var(--app-muted-color); } @@ -196,7 +227,7 @@ margin: 0; } - .gridjs-summary { + .gridjs-pagination .gridjs-summary { font-size: var(--text-xs); color: var(--app-muted-color); } @@ -205,133 +236,297 @@ display: inline-flex; align-items: center; gap: 0.35rem; + margin-inline-start: auto; } - .gridjs-pagination .gridjs-pages button { + /* Pagination buttons follow the project's Pico-style theming convention: + the base `button` rule in app-shell.css redefines `--app-color` and + `--app-background-color` as button-local custom properties (see + app-shell.css ~line 812). A rule like `color: var(--app-color)` inside + the button scope resolves to `--app-primary-inverse` (white) — that is + the origin of the "white-on-white" bug. We MUST theme by redefining the + same custom properties, not by setting `color`/`background-color` + directly, and we must use a token that isn't shadowed inside the button + scope (`--app-form-element-color` fits). */ + .gridjs-pagination .gridjs-pages button, + .gridjs-pagination .gridjs-pages button[type="button"] { + --app-background-color: var(--app-form-element-background-color); + --app-color: var(--app-form-element-color); + --app-border-color: var(--app-table-border-color); + --app-box-shadow: none; margin: 0; display: inline-flex; align-items: center; justify-content: center; - background-color: var(--app-form-element-background-color); - border: var(--app-border-width) solid var(--app-table-border-color); border-radius: calc(var(--app-border-radius) * 0.9); - color: var(--app-color); - padding: 0.2rem 0.62rem; + padding: 0.25rem 0.65rem; line-height: 1.3; min-width: 2rem; white-space: nowrap; - transition: background-color 0.12s ease, border-color 0.12s ease, color 0.12s ease; + font-weight: 500; } - .gridjs-pagination .gridjs-pages button:hover:not(:disabled) { - background-color: var(--app-primary-hover-background); - border-color: var(--app-primary-hover-border); - color: var(--app-primary-inverse); + .gridjs-pagination .gridjs-pages button:hover:not(:disabled), + .gridjs-pagination .gridjs-pages button[type="button"]:hover:not(:disabled), + .gridjs-pagination .gridjs-pages button:focus:not(:disabled), + .gridjs-pagination .gridjs-pages button[type="button"]:focus:not(:disabled) { + --app-background-color: var(--app-primary-hover-background); + --app-border-color: var(--app-primary-hover-border); + --app-color: var(--app-primary-inverse); } .gridjs-pagination .gridjs-pages button:focus-visible { + outline: none; box-shadow: 0 0 0 var(--app-outline-width) var(--app-primary-focus); } - .gridjs-pagination .gridjs-pages button.gridjs-currentPage { - background-color: var(--app-primary); - border-color: var(--app-primary); - color: var(--app-secondary-inverse); - font-weight: 500; + .gridjs-pagination .gridjs-pages button.gridjs-currentPage, + .gridjs-pagination .gridjs-pages button.gridjs-currentPage[type="button"], + .gridjs-pagination .gridjs-pages button.gridjs-currentPage:hover, + .gridjs-pagination .gridjs-pages button.gridjs-currentPage:focus { + --app-background-color: var(--app-primary); + --app-border-color: var(--app-primary); + --app-color: var(--app-primary-inverse); + font-weight: 600; } .gridjs-pagination .gridjs-pages button:disabled, .gridjs-pagination .gridjs-pages button[disabled] { - background-color: var(--app-background-color); - color: var(--app-muted-color); + --app-background-color: var(--app-background-color); + --app-color: var(--app-muted-color); + --app-border-color: var(--app-table-border-color); cursor: default; - border-color: var(--app-table-border-color); - opacity: 0.8; + opacity: 0.7; } - .gridjs-pagination .gridjs-pages button.gridjs-spread { - background: transparent; - border-color: transparent; - color: var(--app-muted-color); + .gridjs-pagination .gridjs-pages button.gridjs-spread, + .gridjs-pagination .gridjs-pages button.gridjs-spread[type="button"] { + --app-background-color: transparent; + --app-border-color: transparent; + --app-color: var(--app-muted-color); min-width: auto; padding-inline: 0.35rem; + pointer-events: none; } + /* ------------------------------------------------------------------- + Loading Skeleton (Stripe/GitHub goldstandard) + + Rendered as a real 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');