diff --git a/web/css/components/app-list-toolbar.css b/web/css/components/app-list-toolbar.css index 92ab648..3bd8e4c 100644 --- a/web/css/components/app-list-toolbar.css +++ b/web/css/components/app-list-toolbar.css @@ -1,48 +1,46 @@ @layer components { /* List toolbar — page-size selector, filter toggle, and search controls above grids. */ .app-list-toolbar { + --app-list-toolbar-gap: 10px; + --app-list-toolbar-padding-x: 10px; + --app-list-toolbar-padding-y: 6px; display: flex; align-items: flex-end; flex-wrap: wrap; - gap: 10px; - margin-bottom: calc(var(--app-spacing) * 1); + gap: var(--app-list-toolbar-gap); + margin-bottom: var(--app-spacing); } .app-list-toolbar > * { - margin: 0 !important; - width: auto !important; - --app-form-element-spacing-horizontal: 10px; - --app-form-element-spacing-vertical: 6px; - font-size: var(--text-sm) !important; - height: auto !important; - } - .app-list-toolbar > * > * { - margin: 0 !important; - width: auto !important; - --app-form-element-spacing-horizontal: 10px; - --app-form-element-spacing-vertical: 6px; - font-size: var(--text-sm) !important; - height: auto !important; - } - .app-list-toolbar > * > * > * { - margin: 0 !important; - width: auto !important; - --app-form-element-spacing-horizontal: 10px; - --app-form-element-spacing-vertical: 6px; - font-size: var(--text-sm) !important; - height: auto !important; + margin: 0; + width: auto; + font-size: var(--text-sm); + height: auto; } - .app-list-toolbar > button { - padding: 6px 10px; + .app-list-toolbar :is(.app-field, label, button, [role="button"], input, select, .multi-select) { + margin: 0; + width: auto; + } + + .app-list-toolbar :is(button, [role="button"], input, select) { + --app-form-element-spacing-horizontal: var(--app-list-toolbar-padding-x); + --app-form-element-spacing-vertical: var(--app-list-toolbar-padding-y); + font-size: var(--text-sm); + min-height: calc(var(--app-list-toolbar-padding-y) * 2 + 1.4em); + } + + .app-list-toolbar > button, + .app-list-toolbar > [role="button"] { + padding: var(--app-list-toolbar-padding-y) var(--app-list-toolbar-padding-x); } .app-list-toolbar input:not( [type="checkbox"], - .app-list-toolbar [type="radio"], - .app-list-toolbar [type="range"], - .app-list-toolbar [type="file"] + [type="radio"], + [type="range"], + [type="file"] ):is( [type="date"], [type="datetime-local"], @@ -53,29 +51,26 @@ width: auto; } - .app-list-toolbar > label > span { + .app-list-toolbar > label > span, + .app-list-toolbar > .app-field > span { display: block; - font-size: var(--text-xs) !important; + font-size: var(--text-xs); + margin-bottom: 2px; + } + + .app-list-toolbar .app-grid-page-size { + margin-inline-start: auto; } .app-list-toolbar .app-grid-page-size select { min-width: 84px; } - .app-list-toolbar .app-grid-page-size { - margin-inline-start: auto !important; + .app-list-toolbar .multi-select { + width: 200px; } - @media (max-width: 768px) { - .app-list-toolbar .app-grid-page-size { - margin-inline-start: 0 !important; - } - } - - .app-list-toolbar - .multi-select - .multi-select-header - .multi-select-header-placeholder { + .app-list-toolbar .multi-select .multi-select-header .multi-select-header-placeholder { font-size: var(--text-sm); } @@ -86,15 +81,16 @@ padding-left: 5px; } - .app-list-toolbar .multi-select { - width: 200px !important; + .app-list-toolbar span.multi-select-header-option { + background: transparent; + border: 0; + padding: 0 0 0 3px; + font-size: var(--text-xs); } - .app-list-toolbar span.multi-select-header-option { - background: transparent !important; - border: 0 !important; - padding: 0 !important; - padding-left: 3px !important; - font-size: var(--text-xs) !important; + @media (max-width: 768px) { + .app-list-toolbar .app-grid-page-size { + margin-inline-start: 0; + } } } diff --git a/web/css/vendor-overrides/gridjs.css b/web/css/vendor-overrides/gridjs.css index ccdbe32..68c48f0 100644 --- a/web/css/vendor-overrides/gridjs.css +++ b/web/css/vendor-overrides/gridjs.css @@ -1,46 +1,87 @@ @layer vendor-overrides { .gridjs-container { + --app-grid-cell-pad-y: calc(var(--app-spacing) * 0.55); + --app-grid-cell-pad-x: var(--app-spacing); + --app-grid-row-hover: color-mix( + in srgb, + var(--app-primary) 4%, + var(--app-background-color) + ); + --app-grid-row-focus: color-mix( + in srgb, + var(--app-primary) 7%, + var(--app-background-color) + ); + --app-grid-row-selected: color-mix( + in srgb, + var(--app-primary) 10%, + var(--app-background-color) + ); + --app-grid-row-selected-hover: color-mix( + in srgb, + var(--app-primary) 14%, + var(--app-background-color) + ); color: var(--app-color); width: 100%; padding: 0; } + .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); + } + + .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); + } + .gridjs-wrapper { - border: 0; - border-radius: 0; + position: relative; + border: var(--app-border-width) solid var(--app-table-border-color); + border-radius: calc(var(--app-border-radius) * 1.4); box-shadow: none; background-color: var(--app-background-color); - border-top: var(--app-border-width) solid var(--app-table-border-color); - padding-block-end: 1rem; - min-height: 515px; overflow-x: auto; overflow-y: hidden; } + .gridjs-container.gridjs-has-error .gridjs-wrapper { + border-color: color-mix(in srgb, var(--app-danger, #b44545) 40%, var(--app-table-border-color)); + } + .gridjs-footer { - background-color: var(--app-background-color); + background-color: transparent; border: 0; - border-top: 0; box-shadow: none; color: var(--app-muted-color); - padding: 1rem 0 0; + padding: 0.85rem 0 0; } .gridjs-table { color: var(--app-color); min-width: 100%; width: max-content; + table-layout: auto; } th.gridjs-th { - padding: calc(var(--app-spacing) / 2) var(--app-spacing); - border: none; + padding: var(--app-grid-cell-pad-y) var(--app-grid-cell-pad-x); + border: 0; border-bottom: var(--app-border-width) solid var(--app-table-border-color); - background-color: var(--app-background-color); + background-color: color-mix( + in srgb, + var(--app-background-color) 94%, + var(--app-muted-border-color) + ); color: var(--app-color); - font-weight: var(--app-font-weight); + font-weight: 600; + line-height: 1.25; + height: 2.55rem; text-align: left; text-align: start; + white-space: nowrap; } /* Match content line-height to sort button height (24px) for uniform th height */ @@ -48,22 +89,74 @@ line-height: 24px; } + .gridjs-wrapper .gridjs-th-fixed { + position: sticky; + top: 0; + z-index: 3; + box-shadow: 0 1px 0 0 var(--app-table-border-color); + } + th.gridjs-th-sort:hover, - th.gridjs-th-sort:focus { - background-color: var(--app-table-row-stripped-background-color); + th.gridjs-th-sort:focus-visible { + background-color: color-mix( + in srgb, + var(--app-primary) 6%, + var(--app-background-color) + ); } td.gridjs-td { - background-color: var(--app-background-color); - border: none; - border-color: var(--app-table-border-color); - padding: calc(var(--app-spacing) / 2) var(--app-spacing); + border: 0; border-bottom: var(--app-border-width) solid var(--app-table-border-color); + padding: var(--app-grid-cell-pad-y) var(--app-grid-cell-pad-x); background-color: var(--app-background-color); color: var(--app-color); font-weight: var(--app-font-weight); + line-height: 1.35; + height: 2.75rem; + white-space: nowrap; text-align: left; text-align: start; + vertical-align: middle; + } + + .gridjs-tr:last-child td.gridjs-td { + border-bottom: 0; + } + + .gridjs-tbody, + td.gridjs-td { + background-color: var(--app-background-color); + } + + .gridjs-tr[data-row-open-url] { + cursor: pointer; + } + + .gridjs-tr[data-row-open-url]:hover td { + background: var(--app-grid-row-hover); + } + + .gridjs-tr:not([data-row-open-url]):hover td { + background: var(--app-background-color); + } + + .gridjs-tr[data-row-open-url]:focus td, + .gridjs-tr[data-row-open-url]:focus-within td { + background: var(--app-grid-row-focus); + } + + .gridjs-tr[data-row-open-url]:focus-visible td { + background: var(--app-grid-row-focus); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--app-primary) 24%, transparent); + } + + .gridjs-tr.gridjs-tr-selected td { + background: var(--app-grid-row-selected); + } + + .gridjs-tr.gridjs-tr-selected[data-row-open-url]:hover td { + background: var(--app-grid-row-selected-hover); } .gridjs-search { @@ -89,31 +182,62 @@ } .gridjs-pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.65rem; color: var(--app-muted-color); } - .gridjs-pagination .gridjs-pages button { - background-color: var(--app-background-color); - border: var(--app-border-width) solid var(--app-table-border-color); - color: var(--app-color); - padding: 1px 10px; + .gridjs-pagination .gridjs-summary, + .gridjs-pagination .gridjs-pages { + float: none; + margin: 0; } - .gridjs-pagination .gridjs-pages button:hover { + .gridjs-summary { + font-size: var(--text-xs); + color: var(--app-muted-color); + } + + .gridjs-pagination .gridjs-pages { + display: inline-flex; + align-items: center; + gap: 0.35rem; + } + + .gridjs-pagination .gridjs-pages button { + 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; + 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; + } + + .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:focus { + .gridjs-pagination .gridjs-pages button:focus-visible { 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) !important; - font-weight: 400; - padding: 2px 10px; - color: var(--app-secondary-inverse) !important; + background-color: var(--app-primary); + border-color: var(--app-primary); + color: var(--app-secondary-inverse); + font-weight: 500; } .gridjs-pagination .gridjs-pages button:disabled, @@ -121,18 +245,105 @@ background-color: var(--app-background-color); color: var(--app-muted-color); cursor: default; - padding: 2px 10px; - border: 0; + border-color: var(--app-table-border-color); + opacity: 0.8; } - .gridjs-pagination .gridjs-pages { - display: inline-flex; - gap: 5px; + .gridjs-pagination .gridjs-pages button.gridjs-spread { + background: transparent; + border-color: transparent; + color: var(--app-muted-color); + min-width: auto; + padding-inline: 0.35rem; } .gridjs-loading-bar { background-color: var(--app-background-color); - opacity: 0.7; + opacity: 0.65; + } + + .gridjs-loading-bar:after { + background-image: linear-gradient( + 90deg, + transparent, + var(--app-primary-focus), + transparent + ); + } + + .gridjs-container.app-grid-loading-skeleton .gridjs-loading-bar { + display: none; + } + + .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; + 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; + } + + .gridjs-container.app-grid-loading-skeleton.gridjs-is-updating .gridjs-message.gridjs-notfound { + display: none; + } + + .gridjs-container.app-grid-loading-skeleton.gridjs-is-updating .gridjs-tbody .gridjs-td { + position: relative; + color: transparent; + pointer-events: none; + } + + .gridjs-container.app-grid-loading-skeleton.gridjs-is-updating .gridjs-tbody .gridjs-td::after { + 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; + } + + .gridjs-container.app-grid-loading-skeleton.gridjs-is-updating .gridjs-tbody .gridjs-td > * { + visibility: hidden; + } + + .gridjs-message { + padding: 1rem; + color: var(--app-muted-color); + } + + .gridjs-message.gridjs-error { + color: var(--app-color); + border: var(--app-border-width) solid color-mix(in srgb, var(--app-danger, #b44545) 25%, transparent); + border-radius: calc(var(--app-border-radius) * 1.1); + background: color-mix(in srgb, var(--app-danger, #b44545) 8%, var(--app-background-color)); } .gridjs-message.gridjs-error .app-grid-error-actions { @@ -145,6 +356,16 @@ line-height: 1.2; } + .gridjs-message.gridjs-notfound { + color: var(--app-muted-color); + background: color-mix(in srgb, var(--app-muted-border-color) 18%, transparent); + border-radius: calc(var(--app-border-radius) * 1.1); + } + + .gridjs-container.gridjs-is-updating .gridjs-message.gridjs-notfound { + display: none; + } + .grid-avatar { --grid-avatar-width: 36px; --grid-avatar-height: 36px; @@ -218,36 +439,11 @@ font-weight: 500; } - .gridjs-loading-bar:after { - background-image: linear-gradient( - 90deg, - transparent, - var(--app-primary-focus), - transparent - ); - } - - /* .gridjs-container:not(.gridjs-has-loaded) .gridjs-message.gridjs-notfound { - display: none; - } */ - - .gridjs-container.gridjs-is-updating .gridjs-message.gridjs-notfound { - display: none; - } - - .gridjs-pages button { - background: var(--app-form-element-background-color) !important; - color: var(--app-contrast) !important; - } - - .gridjs-summary { - font-size: small; - } - .gridjs-container .grid-actions { display: inline-flex; align-items: center; gap: 0.35rem; + white-space: nowrap; } .app-grid-link-cell { @@ -266,56 +462,22 @@ border-radius: 2px; } - tr.gridjs-tr[data-row-open-url] { - cursor: pointer; - } - - tr.gridjs-tr:hover td { - background: var(--app-table-row-stripped-background-color); - } - - tr.gridjs-tr[data-row-open-url]:focus td, - tr.gridjs-tr[data-row-open-url]:focus-within td { - background: var(--app-table-row-stripped-background-color); - background: color-mix( - in srgb, - var(--app-primary) 4%, - var(--app-background-color) - ); - } - - tr.gridjs-tr[data-row-open-url]:focus-visible td { - background: var(--app-table-row-stripped-background-color); - background: color-mix( - in srgb, - var(--app-primary) 6%, - var(--app-background-color) - ); - } - - tr.gridjs-tr[data-row-open-url]:focus-visible { - outline: none; - } - - html[data-theme="dark"] button.gridjs-sort, - html[data-theme="dark"] button.gridjs-sort-neutral { - filter: invert(1); - } - - html[data-theme="dark-green"] button.gridjs-sort, - html[data-theme="dark-green"] button.gridjs-sort-neutral { - filter: invert(1); - } - - .gridjs-tbody, - td.gridjs-td { - background-color: var(--app-background-color); - } - .gridjs-table input[data-grid-select-all] { margin: 0; } + .gridjs-td .badge, + .gridjs-td .grid-name-cell, + .gridjs-td .grid-tenant-profile, + .gridjs-td .app-inline-actions { + white-space: nowrap; + } + + .gridjs-td .badge-list { + align-items: center; + white-space: nowrap; + } + /* Reset button margin inside grid cells (Pico/framework default adds margin-bottom) */ td.gridjs-td button, td.gridjs-td [role="button"] { @@ -329,30 +491,16 @@ align-items: center; } - - - - - .gridjs-pagination .gridjs-pages button:first-child, - .gridjs-pagination .gridjs-pages button:last-child { - font-size: 0; - line-height: 1; - padding: 2px 8px; + html[data-theme="dark"] button.gridjs-sort, + html[data-theme="dark"] button.gridjs-sort-neutral { + filter: invert(1); } - .gridjs-pagination .gridjs-pages button:first-child::before, - .gridjs-pagination .gridjs-pages button:last-child::before { - font-family: "Bootstrap-icons"; - font-size: var(--text-sm, 0.875rem); + html[data-theme="dark-green"] button.gridjs-sort, + html[data-theme="dark-green"] button.gridjs-sort-neutral { + filter: invert(1); } - .gridjs-pagination .gridjs-pages button:first-child::before { - content: "\F284"; - } - - .gridjs-pagination .gridjs-pages button:last-child::before { - content: "\F285"; - } button.gridjs-sort { background-color: transparent; background-position-x: center; @@ -367,4 +515,28 @@ padding: 0; width: 13px; } + + @media (max-width: 768px) { + .gridjs-wrapper { + border-radius: var(--app-border-radius); + } + + .gridjs-pagination { + align-items: stretch; + } + + .gridjs-pagination .gridjs-pages { + max-width: 100%; + flex-wrap: wrap; + } + } + + @keyframes app-grid-skeleton-shimmer { + 0% { + background-position-x: -240px, 0; + } + 100% { + background-position-x: calc(100% + 240px), 0; + } + } } diff --git a/web/js/core/app-grid-factory.js b/web/js/core/app-grid-factory.js index 9cf69d7..403a685 100644 --- a/web/js/core/app-grid-factory.js +++ b/web/js/core/app-grid-factory.js @@ -8,6 +8,9 @@ import { buildChipsFromMeta, removeChipFromMetaState, clearMetaState } from '../ const DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; const PAGE_SIZE_STORAGE_PREFIX = 'grid:page-size'; +const DEFAULT_GRID_HEIGHT = 'min(68vh, 760px)'; +const GRID_DENSITY_VALUES = new Set(['sm', 'md', 'lg']); +const GRID_LOADING_MODE_VALUES = new Set(['skeleton', 'bar']); const parsePositiveInt = (value) => { const parsed = Number.parseInt(String(value ?? ''), 10); @@ -31,6 +34,16 @@ const normalizePageSizeOptions = (options) => { return normalized.length ? normalized : [...DEFAULT_PAGE_SIZE_OPTIONS]; }; +const normalizeGridDensity = (value) => { + const normalized = String(value ?? '').trim().toLowerCase(); + return GRID_DENSITY_VALUES.has(normalized) ? normalized : 'md'; +}; + +const normalizeLoadingMode = (value) => { + const normalized = String(value ?? '').trim().toLowerCase(); + return GRID_LOADING_MODE_VALUES.has(normalized) ? normalized : 'skeleton'; +}; + const escapeHtmlAttr = (value) => String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') @@ -101,7 +114,8 @@ export function createServerGrid(options) { rowInteraction = null, rowDataset = null, selection = null, - pageSize = null + pageSize = null, + ui = null } = options || {}; const normalizedFilterBindingMode = String(filterBindingMode || 'live').trim().toLowerCase() === 'manual' @@ -120,6 +134,19 @@ export function createServerGrid(options) { return null; } + const uiConfig = ui && typeof ui === 'object' ? ui : {}; + const stickyHeaderEnabled = uiConfig.stickyHeader !== false; + const gridHeight = typeof uiConfig.height === 'string' && uiConfig.height.trim() !== '' + ? uiConfig.height.trim() + : DEFAULT_GRID_HEIGHT; + const loadingMode = normalizeLoadingMode(uiConfig.loadingMode); + const densityMode = normalizeGridDensity(uiConfig.density); + + containerEl.classList.remove('app-grid-density-sm', 'app-grid-density-md', 'app-grid-density-lg'); + containerEl.classList.add(`app-grid-density-${densityMode}`); + containerEl.classList.toggle('app-grid-loading-bar', loadingMode === 'bar'); + containerEl.classList.toggle('app-grid-loading-skeleton', loadingMode === 'skeleton'); + const normalizedColumns = normalizeGridColumns(columns, sortColumns); const params = new URLSearchParams(window.location.search); @@ -688,6 +715,8 @@ export function createServerGrid(options) { search: false, sort: sortConfig, pagination: paginationConfig, + fixedHeader: stickyHeaderEnabled, + ...(stickyHeaderEnabled ? { height: gridHeight } : {}), language, plugins }); @@ -706,6 +735,8 @@ export function createServerGrid(options) { }; const setUpdating = () => containerEl.classList.add('gridjs-is-updating'); const clearUpdating = () => containerEl.classList.remove('gridjs-is-updating'); + const setErrorState = () => containerEl.classList.add('gridjs-has-error'); + const clearErrorState = () => containerEl.classList.remove('gridjs-has-error'); const ensureErrorRetryControl = () => { const errorMessage = containerEl.querySelector('.gridjs-message.gridjs-error'); if (!errorMessage) { @@ -738,9 +769,12 @@ export function createServerGrid(options) { clearUpdating(); } if (state.status === 4) { + setErrorState(); setTimeout(() => { ensureErrorRetryControl(); }, 0); + } else { + clearErrorState(); } if (state.status >= 2) { markLoadedOnce(); @@ -764,8 +798,12 @@ export function createServerGrid(options) { if (rowOpenUrl !== '') { rowEl.setAttribute('data-row-open-url', rowOpenUrl); rowEl.setAttribute('tabindex', '-1'); + rowEl.setAttribute('title', rowEnterHint); + rowEl.setAttribute('aria-label', rowEnterHint); } else { rowEl.removeAttribute('data-row-open-url'); + rowEl.removeAttribute('title'); + rowEl.removeAttribute('aria-label'); if (rowEl.getAttribute('tabindex') === '-1') { rowEl.removeAttribute('tabindex'); }