Grid v1.0
The <e-grid> component is a component that can be used for displaying a variety of grids.
Usage
An e-grid has attributes that must be provided.
<e-grid
:loading="grid.loading"
:items="grid.items"
:grid-model="grid.model"
:page="grid.currentPage"
:total="grid.totalItemCount"
:query-parameters="urlParams"
@row-clicked="rowClicked"
@column:resize="columnResized"
@column:switch="columnSwitched"
@emit-key="gridEvents"
/>
Attributes
gridModel | Type: GridData | required
loading | Type: Boolean | Default:false
items | Type: Object[] | Default:[]
errors | Type: Object[] | Default:[]
queryParameters | Type: QueryParameters | Default:{}
total | Type: Number | Default:0
page | Type: Number | Default:0
pagerDisplayOffset | Type: Number | Default:2
Examples
id
name
isActive
tags
parties
changedOn
createdOn
1
test 1
test party
partytest party 2
party 210/10/20251 second ago
10/10/20251 second ago
2
test 2
10/10/20251 second ago
10/10/20251 second ago
3
test 3
10/10/20251 second ago
10/10/20251 second ago
4
test 4
10/10/20251 second ago
10/10/20251 second ago
5
test 5
10/10/20251 second ago
10/10/20251 second ago
6
test 6
10/10/20251 second ago
10/10/20251 second ago
7
test 7
10/10/20251 second ago
10/10/20251 second ago
8
test 8
10/10/20251 second ago
10/10/20251 second ago
9
test 9
10/10/20251 second ago
10/10/20251 second ago
10
test 10
10/10/20251 second ago
10/10/20251 second ago
Source Code
e-grid.vue
<script setup lang="ts">
import { computed, provide, ref, watch } from 'vue';
import { isEmpty, filter, orderBy, size, chunk, some } from 'lodash-es';
import { useI18n } from 'vue-i18n';
import { ColumnModel, GridColumnTypes, GridData, GridEmit, QueryParameters } from 'types/grid';
import { Dictionary } from 'types/Dictionary';
import EGridSupportWarning from '../e-grid-support-warning/e-grid-support-warning.vue';
import EGridHeader from '../e-grid-header/e-grid-header.vue';
import EGridError from '../e-grid-error/e-grid-error.vue';
import EGridRow from '../e-grid-row/e-grid-row.vue';
import EGridFooter from '../e-grid-footer/e-grid-footer.vue';
import ELoadingIndicator from '../e-loading-indicator/e-loading-indicator.vue';
export type Props = {
gridModel: GridData;
loading?: boolean;
items?: object[];
errors?: string[];
queryParameters?: QueryParameters;
total?: number;
page?: number;
pagerDisplayOffset?: number;
settings?: Dictionary & {
activeOnly: boolean;
};
};
const props = withDefaults(defineProps<Props>(), {
loading: false,
items: () => {
return [];
},
errors: () => {
return [];
},
queryParameters: () => {
return {
sort: [],
filter: [],
skip: 0,
take: 0
};
},
total: 0,
page: 0,
pagerDisplayOffset: 2,
settings: () => {
return {
activeOnly: true
};
}
});
const emit = defineEmits<{
(e: 'click', column: ColumnModel): void;
(e: 'column:resize', column: ColumnModel): void;
(e: 'column:switch', originalColumn: ColumnModel, targetColumn: ColumnModel): void;
(e: 'emit-key', emitKey: string, rowData: GridEmit, functionMeta?: object): void;
(e: 'grid:reload', params: QueryParameters): void;
(e: 'row-clicked', row: object): void;
(e: 'sorted', column: ColumnModel): void;
}>();
const { t } = useI18n({ useScope: 'global' });
provide(
'grid-id',
computed(() => currentGridModel.value.id)
);
provide(
'grid-loading-state',
computed(() => props.loading)
);
provide(
'grid-size',
computed(() => {
return {
rowHeight: rowHeight.value,
contentHeight: contentHeight.value
};
})
);
provide(
'grid-model',
computed(() => currentGridModel.value)
);
provide(
'grid-columns',
computed(() => gridColumns.value)
);
provide(
'grid-count:prop',
computed(() => itemCount.value)
);
provide(
'grid-count:total',
computed(() => totalItemCount.value)
);
provide(
'grid-count:pages',
computed(() => pageCount.value || 1)
);
provide(
'grid-page',
computed(() => currentPage.value)
);
provide(
'grid-page-size',
computed(() => pageSize.value || 1)
);
provide(
'grid-query-params',
computed(() => urlParams.value)
);
const isLoading = computed(() => {
return props.loading;
});
const hasError = computed(() => {
return !isEmpty(props.errors);
});
const contentClasses = computed(() => {
return {
loading: isLoading.value,
error: hasError.value
};
});
const gridSettings = computed(() => {
return props.settings;
});
const urlParams = computed(() => {
return props.queryParameters;
});
const pageSize = computed(() => {
return urlParams.value.take;
});
const currentPage = computed(() => {
return props.page;
});
const currentGridModel = computed(() => {
return props.gridModel;
});
const activeColumns = computed(() =>
filter(currentGridModel.value?.columns || {}, column => column.active)
);
const gridColumns = computed(() => {
if (gridSettings.value.activeOnly) return orderBy(activeColumns.value, ['index'], ['asc']);
return orderBy(currentGridModel.value?.columns || {}, ['index'], ['asc']);
});
const hasItems = computed(() => !isEmpty(props.items));
const itemCount = computed(() => size(props.items) || 0);
const totalItemCount = computed(() => {
return props.total || itemCount.value;
});
const pageCount = computed(() => {
if (totalItemCount.value && pageSize.value)
return Math.ceil(totalItemCount.value / pageSize.value);
return Math.max(1, size(pageData.value));
});
const pageData = computed(() => {
const dataList = hasItems.value ? props.items : [];
return chunk(dataList, pageSize.value);
});
const currentPageGridRowData = computed(() => {
if (pageData.value[currentPage.value]) return pageData.value[currentPage.value] as object[];
return (pageData.value?.[0] || []) as object[];
});
const manualRowHeight = ref(40);
const useDynamicHeight = ref(false);
const detectedBrowserAgent = computed(() => {
if (typeof window !== 'undefined') {
if (window) {
// Get the user-agent string
let userAgentString = navigator.userAgent;
// Detect Chrome
let chromeAgent = userAgentString.indexOf('Chrome') > -1;
// Detect Internet Explorer
let IExplorerAgent =
userAgentString.indexOf('MSIE') > -1 || userAgentString.indexOf('rv:') > -1;
// Detect Firefox
let firefoxAgent = userAgentString.indexOf('Firefox') > -1;
// Detect Safari
let safariAgent = userAgentString.indexOf('Safari') > -1;
// Discard Safari since it also matches Chrome
if (chromeAgent && safariAgent) safariAgent = false;
// Detect Opera
let operaAgent = userAgentString.indexOf('OP') > -1;
// Discard Chrome since it also matches Opera
if (chromeAgent && operaAgent) chromeAgent = false;
return {
safariAgent: safariAgent,
chromeAgent: chromeAgent,
IExplorerAgent: IExplorerAgent,
operaAgent: operaAgent,
firefoxAgent: firefoxAgent
};
}
}
return {
safariAgent: false,
chromeAgent: false,
IExplorerAgent: false,
operaAgent: false,
firefoxAgent: false
};
});
const supportWarning = computed(() => {
const result = [];
if (detectedBrowserAgent.value.safariAgent) result.push(t('grid.warning.support', ['Safari']));
return result;
});
const rowHeight = computed(() => {
// TODO: refined row height requirement checking.
// const partyColumns = filter(gridColumns.value, { 'type': GridColumnTypes.parties })
// if (!isEmpty(partyColumns)) {
// if (some(partyColumns, column => checkRequirements(column, partyRequirements.value)))
// return 120
// }
if (some(gridColumns.value, { type: GridColumnTypes.parties })) return 100;
if (some(gridColumns.value, { type: GridColumnTypes.tags })) return 60;
return manualRowHeight.value;
});
const contentHeight = computed(() => {
let rowCount = pageSize.value;
if (useDynamicHeight.value) {
rowCount = Math.min(pageSize.value, currentPageGridRowData.value?.length);
}
return rowCount * rowHeight.value;
});
const contentStyle = computed(() => {
return {
height: contentHeight.value + 'px'
};
});
function rowClicked(rowData: object) {
emit('row-clicked', rowData);
}
function emitKey(emitKey: string, rowData: GridEmit, functionMeta: object = undefined) {
emit('emit-key', emitKey, rowData, functionMeta);
}
function resizeColumn(updatedColumn: ColumnModel) {
emit('column:resize', updatedColumn);
}
function switchColumnIndex(initialColumn: ColumnModel, targetColumn: ColumnModel) {
emit('column:switch', initialColumn, targetColumn);
}
function handleSort(targetColumn: ColumnModel) {
emit('sorted', targetColumn);
}
function reload(params: QueryParameters) {
emit('grid:reload', params);
}
function handleEmit(emitKey: string, target: string | number | ColumnModel) {
emit('emit-key', emitKey, target);
}
const scrollTarget = ref<HTMLElement>();
function scrollToTop() {
// Due to sticky header, instantly scroll to an offset scroll target.
const scrollTargetElement = scrollTarget.value;
scrollTargetElement?.scrollIntoView({ behavior: 'auto' });
}
const gridContent = ref<HTMLDivElement>();
const gridContentWrapper = ref<HTMLDivElement>();
const gridErrors = ref<HTMLDivElement>();
function catchScroll() {
const content = gridContent.value;
const contentWrapper = gridContentWrapper.value;
const scrollLeft = contentWrapper?.scrollLeft || 0;
if (content && content.style) {
content.style.left = `${scrollLeft}px`;
content.scrollTo({
left: scrollLeft,
top: content?.scrollTop,
behavior: 'instant'
});
}
}
function resetScrolling() {
gridContent.value.scrollTo({
left: 0,
top: gridContent.value?.scrollTop,
behavior: 'instant'
});
gridContentWrapper.value.scrollTo({
left: 0,
top: gridContentWrapper.value?.scrollTop,
behavior: 'instant'
});
}
watch(
() => props.loading,
async newValue => {
if (newValue) {
// When loading starts, scroll to top.
scrollToTop();
catchScroll();
}
}
);
watch(
() => currentPage.value,
() => {
resetScrolling();
}
);
</script>
<template>
<div class="e-grid">
<div
v-if="detectedBrowserAgent.safariAgent"
class="support-warning"
>
<e-grid-support-warning :warnings="supportWarning" />
</div>
<template v-else>
<div class="grid-scroll-container">
<div
ref="gridContentWrapper"
class="grid-content-wrapper"
:class="contentClasses"
@scroll="catchScroll"
>
<div class="grid-header">
<e-grid-header
@column:resize="resizeColumn"
@column:switch="switchColumnIndex"
@sorted="handleSort"
/>
</div>
<e-grid-error
v-if="hasError"
ref="gridErrors"
:errors="errors"
/>
<div
ref="gridContent"
class="grid-content"
:class="contentClasses"
:style="contentStyle"
>
<div
ref="scrollTarget"
class="scroll-target"
aria-hidden="true"
/>
<e-loading-indicator v-if="loading" />
<template v-else-if="hasItems">
<e-grid-row
v-for="(rowData, rowIndex) in currentPageGridRowData"
:key="rowIndex"
:row-data="rowData"
@click="rowClicked(rowData)"
@emit-key="emitKey"
/>
</template>
<div
v-else
class="no-results"
>
<h3
class="no-results-text"
v-text="$t('grid.noResults')"
/>
</div>
</div>
</div>
</div>
<div class="grid-footer">
<client-only>
<e-grid-footer
:current-page="currentPage"
:display-offset="pagerDisplayOffset"
@paging="reload"
@emit-key="handleEmit"
/>
<template #fallback>
<e-loading-indicator />
</template>
</client-only>
</div>
</template>
</div>
</template>
<style scoped lang="scss">
$grid-header-height: 70px;
$grid-footer-height: 70px;
$error-window-height: 100px;
.e-grid {
background-color: var(--e-background);
border-radius: 15px;
display: flex;
flex-direction: column;
height: 100%;
min-height: calc(75px + $grid-header-height + $grid-footer-height);
width: 100%;
flex: 1 1 auto;
.support-warning {
padding: 20px;
}
.grid-scroll-container {
width: 100%;
overflow: hidden;
border-radius: 15px 15px 0 0;
position: relative;
flex: 1 1 auto;
min-height: fit-content;
.scroll-target {
position: absolute;
top: 0;
}
.grid-content-wrapper {
background-color: var(--e-grid-background);
display: block;
position: absolute;
overflow-x: auto;
overflow-y: hidden;
right: 0;
top: 0;
bottom: 0;
left: 0;
padding-top: $grid-header-height;
}
&::-webkit-scrollbar-track {
background-color: var(--e-white);
&:vertical {
border-radius: 0 15px 0 0;
}
}
}
.grid-header,
.grid-content {
position: absolute;
left: 0;
min-width: 100%;
}
.grid-header {
top: 0;
overflow: hidden;
z-index: 2;
}
.grid-content {
position: absolute;
display: block;
flex-direction: column;
z-index: 1;
transition:
height 0.2s ease-in-out,
left 0s,
max-height 0.3s ease-in-out,
top 0.3s ease-in-out,
width 0s;
top: $grid-header-height;
height: fit-content;
overflow-x: hidden;
overflow-y: auto;
bottom: 0;
max-height: calc(100% - $grid-header-height);
width: 100%;
max-width: 100%;
.loading-indicator {
margin: auto;
}
&.loading {
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
width: 100%;
}
.no-results {
display: flex;
justify-content: center;
.no-results-text {
color: var(--e-secondary);
}
}
&.error {
top: calc($error-window-height + $grid-header-height);
max-height: calc(100% - $error-window-height - $grid-header-height);
}
}
.e-grid-error-window {
height: $error-window-height;
}
.grid-header {
height: $grid-header-height;
}
.grid-footer {
height: $grid-footer-height;
}
}
@media only screen and (max-width: 1200px) {
.e-grid {
height: auto;
.grid-footer {
height: fit-content;
}
}
}
</style>