Vitral 0.2
Data

DataGrid

A data table that sorts, filters, pages and selects, locally or on your server, with columns the reader can resize, reorder, pin and hide.

Import

main.ts
import { DataGrid } from '@vitral/vue';

Sort, filter, page and select

Click a header to sort, type under it to filter (accents are ignored: “sao” finds São Paulo), search every column at once from the header, and tick rows.

People0 selected
People
Ximena Diallo
São Paulo · Brazil
Sep 15, 2020
€4,586.25
Offline
Noémie Nowak
Zürich · Switzerland
Oct 7, 2023
€5,484.63
Offline
Bruno Nowak
Kraków · Poland
Dec 23, 2022
€456.73
Away
Quentin Gómez
Tōkyō · Japan
Nov 26, 2023
€5,444.92
Active
Hanna Mendes
Reykjavík · Iceland
Jun 9, 2021
€16,745.23
Away
Wiktor Lima
Kraków · Poland
Jul 15, 2020
€6,428.86
Active
Bruno Rocha
Tōkyō · Japan
Aug 26, 2021
€8,297.10
Active
Gustavo Rocha
Québec · Canada
Apr 15, 2021
€10,280.87
Active
<script setup lang="ts">import { Column, DataGrid, Icon, InputText, Select, useVitral } from '@vitral/vue';import { computed, ref } from 'vue'; type Status = 'Active' | 'Away' | 'Offline' | 'Blocked'; interface Person {    id: number;    name: string;    city: string;    country: string;    joined: Date;    balance: number;    status: Status;} // A small deterministic generator, so the demo shows the same people every time.let seed = 7;const random = () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646;const pick = <T,>(list: readonly T[]) => list[Math.floor(random() * list.length)]!; const firstNames = ['Ana', 'Bruno', 'Carla', 'Diego', 'Élise', 'Fatou', 'Gustavo', 'Hanna', 'Iñaki', 'João', 'Kenji', 'Lucía', 'Mateus', 'Noémie', 'Oskar', 'Paula', 'Quentin', 'Raquel', 'Søren', 'Tomás', 'Valéria', 'Wiktor', 'Ximena', 'Zoë'];const lastNames = ['Souza', 'Lima', 'Mendes', 'Ferreira', 'Martin', 'Diallo', 'Rocha', 'Müller', 'Etxeberria', 'Pereira', 'Sato', 'Gómez', 'Nowak', 'Dubois', 'Jónsson', 'Costa', 'Fontaine', 'Álvarez'];const places: [string, string][] = [    ['São Paulo', 'Brazil'],    ['Recife', 'Brazil'],    ['Florianópolis', 'Brazil'],    ['Lisboa', 'Portugal'],    ['Évora', 'Portugal'],    ['Montréal', 'Canada'],    ['Québec', 'Canada'],    ['München', 'Germany'],    ['Köln', 'Germany'],    ['Córdoba', 'Argentina'],    ['Bogotá', 'Colombia'],    ['Kraków', 'Poland'],    ['Zürich', 'Switzerland'],    ['Reykjavík', 'Iceland'],    ['Tōkyō', 'Japan'],    ['Málaga', 'Spain']];const statuses: Status[] = ['Active', 'Active', 'Active', 'Away', 'Offline', 'Blocked']; const people: Person[] = Array.from({ length: 64 }, (_, i) => {    const [city, country] = pick(places);    return {        id: i + 1,        name: `${pick(firstNames)} ${pick(lastNames)}`,        city,        country,        joined: new Date(2019 + Math.floor(random() * 7), Math.floor(random() * 12), 1 + Math.floor(random() * 28)),        balance: Math.round(random() * 2_000_000) / 100 - 2000,        status: pick(statuses)    };}); const { config } = useVitral();const money = computed(() => new Intl.NumberFormat(config.locale.code, { style: 'currency', currency: 'EUR' }));const day = computed(() => new Intl.DateTimeFormat(config.locale.code, { dateStyle: 'medium' }));const statusOptions: Status[] = ['Active', 'Away', 'Offline', 'Blocked']; const filters = ref({    global: { value: null as string | null, matchMode: 'contains' },    name: { value: null as string | null, matchMode: 'contains' },    city: { value: null as string | null, matchMode: 'startsWith' },    status: { value: null as Status | null, matchMode: 'equals' }});const selected = ref<Person[]>([]);</script> <template>    <DataGrid        v-model:filters="filters"        v-model:selection="selected"        :value="people"        data-key="id"        caption="People"        filter-display="row"        :global-filter-fields="['name', 'city', 'country']"        paginator        :rows="8"        :rows-per-page-options="[8, 16, 32]"        paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"        removable-sort        striped-rows        class="demo-table"    >        <template #header>            <div class="demo-table-bar">                <strong>People</strong>                <small style="color: var(--vt-text-muted-color)">{{ selected.length }} selected</small>                <InputText v-model="filters.global.value" placeholder="Search people" aria-label="Search people" size="small" clearable class="demo-table-search">                    <template #prefix><Icon icon="search" /></template>                </InputText>            </div>        </template>        <Column selection-mode="multiple" />        <Column field="name" header="Name" sortable filter-placeholder="Name" />        <Column field="city" header="City" sortable filter-placeholder="Starts with">            <template #body="{ data }">                {{ data.city }} <span class="demo-muted">· {{ data.country }}</span>            </template>        </Column>        <Column field="joined" header="Joined" sortable>            <template #body="{ data }">{{ day.format(data.joined) }}</template>        </Column>        <Column field="balance" header="Balance" sortable align="right">            <template #body="{ data }">                <span :class="{ 'demo-negative': data.balance < 0 }">{{ money.format(data.balance) }}</span>            </template>        </Column>        <Column field="status" header="Status" sortable>            <template #body="{ data }">                <span class="demo-status"><span :class="['demo-dot', `demo-dot-${data.status.toLowerCase()}`]" aria-hidden="true" />{{ data.status }}</span>            </template>            <template #filter="{ filterModel }">                <Select v-if="filterModel && 'value' in filterModel" v-model="filterModel.value" :options="statusOptions" placeholder="Any" show-clear size="small" aria-label="Filter Status" fluid />            </template>        </Column>    </DataGrid></template> <style scoped>.demo-table {    width: 100%;} .demo-table-bar {    display: flex;    align-items: center;    gap: 0.75rem;    flex-wrap: wrap;} .demo-table-search {    margin-inline-start: auto;    width: 16rem;} .demo-muted {    color: var(--vt-text-muted-color);} .demo-negative {    color: var(--vt-danger-color);} .demo-status {    display: inline-flex;    align-items: center;    gap: 0.5rem;} .demo-dot {    width: 0.5rem;    height: 0.5rem;    border-radius: 999px;    background: var(--vt-text-muted-color);} .demo-dot-active {    background: var(--vt-success-color);} .demo-dot-away {    background: var(--vt-warn-color);} .demo-dot-blocked {    background: var(--vt-danger-color);}</style>

Multiple sort, single selection, grid lines

Sorted by country, then by balance, highest first. Ctrl-click (⌘-click) a header to add it to the sort. Click a row, or focus the rows and use the arrows and Space, to choose one. The header stays in view while the body scrolls.

Diego FerreiraArgentinaCórdoba
€15,492.29
Noémie FontaineArgentinaCórdoba
€9,472.17
Ximena FerreiraArgentinaCórdoba
€2,865.18
Bruno MartinBrazilFlorianópolis
€17,876.33
Élise DuboisBrazilSão Paulo
€14,269.79
Bruno SouzaBrazilFlorianópolis
€13,363.03
Bruno LimaBrazilSão Paulo
€12,562.40
Ana GómezBrazilRecife
€11,750.51
Fatou FerreiraBrazilSão Paulo
€10,543.41
Oskar MartinBrazilSão Paulo
€10,400.34
Ximena DialloBrazilSão Paulo
€4,586.25
Bruno RochaCanadaMontréal
€17,103.69
João EtxeberriaCanadaMontréal
€11,302.35
Wiktor JónssonCanadaQuébec
€10,857.06
Gustavo RochaCanadaQuébec
€10,280.87
Élise SatoCanadaQuébec
€4,759.90
Hanna SouzaCanadaQuébec
€2,684.86
Ximena SouzaColombiaBogotá
€9,305.62
Valéria FontaineColombiaBogotá
€7,948.90
Ana JónssonColombiaBogotá
€4,625.47
Ana LimaGermanyMünchen
€15,860.46
Kenji CostaGermanyKöln
€11,732.89
Hanna MartinGermanyMünchen
€7,969.50
Mateus DuboisGermanyKöln
€3,976.73
João EtxeberriaGermanyKöln
€3,947.82
Carla LimaGermanyMünchen
€2,002.67
Hanna MendesIcelandReykjavík
€16,745.23
Diego CostaIcelandReykjavík
€13,914.59
Fatou FontaineIcelandReykjavík
€13,839.42
Wiktor FontaineIcelandReykjavík
€13,239.60
Tomás DuboisIcelandReykjavík
€12,242.07
Tomás FerreiraIcelandReykjavík
€8,764.27
Bruno RochaJapanTōkyō
€8,297.10
Kenji CostaJapanTōkyō
€8,098.99
Carla DialloJapanTōkyō
€5,945.52
Quentin GómezJapanTōkyō
€5,444.92
João MartinJapanTōkyō
€5,028.76
Valéria ÁlvarezJapanTōkyō
€2,964.27
Wiktor CostaJapanTōkyō
-€1,790.27
Fatou RochaJapanTōkyō
-€1,940.60
Iñaki DuboisPolandKraków
€17,709.64
Wiktor CostaPolandKraków
€17,144.37
João GómezPolandKraków
€12,230.50
Wiktor LimaPolandKraków
€6,428.86
Wiktor FerreiraPolandKraków
€1,503.44
Wiktor CostaPolandKraków
€504.85
Bruno NowakPolandKraków
€456.73
Wiktor SouzaPolandKraków
-€303.08
Fatou FerreiraPortugalÉvora
€12,014.06
Fatou JónssonPortugalLisboa
€10,703.89
Ana EtxeberriaPortugalLisboa
€7,618.88
Raquel DialloPortugalLisboa
-€493.76
Bruno LimaSpainMálaga
€17,131.64
Noémie SatoSpainMálaga
€9,951.09
João NowakSpainMálaga
€4,612.00
Fatou GómezSpainMálaga
€755.90
Kenji NowakSpainMálaga
-€776.43
Carla FontaineSwitzerlandZürich
€15,325.86
Ximena EtxeberriaSwitzerlandZürich
€11,252.24
Søren MüllerSwitzerlandZürich
€11,236.83
Élise LimaSwitzerlandZürich
€8,895.92
Noémie NowakSwitzerlandZürich
€5,484.63
Zoë MendesSwitzerlandZürich
€2,202.54
Wiktor LimaSwitzerlandZürich
€1,071.43
<script setup lang="ts">import { Column, DataGrid, useVitral } from '@vitral/vue';import { computed, ref } from 'vue'; type Status = 'Active' | 'Away' | 'Offline' | 'Blocked'; interface Person {    id: number;    name: string;    city: string;    country: string;    joined: Date;    balance: number;    status: Status;} // A small deterministic generator, so the demo shows the same people every time.let seed = 7;const random = () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646;const pick = <T,>(list: readonly T[]) => list[Math.floor(random() * list.length)]!; const firstNames = ['Ana', 'Bruno', 'Carla', 'Diego', 'Élise', 'Fatou', 'Gustavo', 'Hanna', 'Iñaki', 'João', 'Kenji', 'Lucía', 'Mateus', 'Noémie', 'Oskar', 'Paula', 'Quentin', 'Raquel', 'Søren', 'Tomás', 'Valéria', 'Wiktor', 'Ximena', 'Zoë'];const lastNames = ['Souza', 'Lima', 'Mendes', 'Ferreira', 'Martin', 'Diallo', 'Rocha', 'Müller', 'Etxeberria', 'Pereira', 'Sato', 'Gómez', 'Nowak', 'Dubois', 'Jónsson', 'Costa', 'Fontaine', 'Álvarez'];const places: [string, string][] = [    ['São Paulo', 'Brazil'],    ['Recife', 'Brazil'],    ['Florianópolis', 'Brazil'],    ['Lisboa', 'Portugal'],    ['Évora', 'Portugal'],    ['Montréal', 'Canada'],    ['Québec', 'Canada'],    ['München', 'Germany'],    ['Köln', 'Germany'],    ['Córdoba', 'Argentina'],    ['Bogotá', 'Colombia'],    ['Kraków', 'Poland'],    ['Zürich', 'Switzerland'],    ['Reykjavík', 'Iceland'],    ['Tōkyō', 'Japan'],    ['Málaga', 'Spain']];const statuses: Status[] = ['Active', 'Active', 'Active', 'Away', 'Offline', 'Blocked']; const people: Person[] = Array.from({ length: 64 }, (_, i) => {    const [city, country] = pick(places);    return {        id: i + 1,        name: `${pick(firstNames)} ${pick(lastNames)}`,        city,        country,        joined: new Date(2019 + Math.floor(random() * 7), Math.floor(random() * 12), 1 + Math.floor(random() * 28)),        balance: Math.round(random() * 2_000_000) / 100 - 2000,        status: pick(statuses)    };}); const { config } = useVitral();const money = computed(() => new Intl.NumberFormat(config.locale.code, { style: 'currency', currency: 'EUR' })); const multiSortMeta = ref([    { field: 'country', order: 1 as const },    { field: 'balance', order: -1 as const }]);const chosen = ref<Person | null>(null);</script> <template>    <DataGrid        v-model:multi-sort-meta="multiSortMeta"        v-model:selection="chosen"        :value="people"        data-key="id"        aria-label="People by country"        sort-mode="multiple"        selection-mode="single"        size="small"        show-gridlines        scrollable        scroll-height="320px"        class="demo-table"    >        <Column field="name" header="Name" sortable />        <Column field="country" header="Country" sortable />        <Column field="city" header="City" sortable />        <Column field="balance" header="Balance" sortable align="right">            <template #body="{ data }">{{ money.format(data.balance) }}</template>        </Column>        <template #footer>            <span>{{ chosen ? `${chosen.name}, ${chosen.city}` : 'No one chosen' }}</span>        </template>    </DataGrid></template> <style scoped>.demo-table {    width: 100%;}</style>

Columns the reader arranges

Drag a header to move a column, or hold Ctrl (⌘) and press an arrow. Drag the edge of a header to set its width — the handle is in the tab order, so the arrow keys do it too, and Shift makes bigger steps. Name and Balance are pinned to the edges and stay while the rest scrolls sideways. The button opens the list of columns. Everything the reader does is one object, `columnLayout`, which an application can store and hand back.

RoleStatusBalance
Ximena DialloBrazilSão PauloOffline
€4,586.25
Noémie NowakSwitzerlandZürichOffline
€5,484.63
Bruno NowakPolandKrakówAway
€456.73
Quentin GómezJapanTōkyōActive
€5,444.92
Hanna MendesIcelandReykjavíkAway
€16,745.23
Wiktor LimaPolandKrakówActive
€6,428.86
Bruno RochaJapanTōkyōActive
€8,297.10
Gustavo RochaCanadaQuébecActive
€10,280.87
Nothing changed yet.
<script setup lang="ts">import { Button, Column, DataGrid, StackPanel, useVitral, type ColumnLayoutLike } from '@vitral/vue';import { computed, ref } from 'vue'; const people = [    { id: 1, name: 'Ximena Diallo', city: 'São Paulo', country: 'Brazil', balance: 4586.25, status: 'Offline' },    { id: 2, name: 'Noémie Nowak', city: 'Zürich', country: 'Switzerland', balance: 5484.63, status: 'Offline' },    { id: 3, name: 'Bruno Nowak', city: 'Kraków', country: 'Poland', balance: 456.73, status: 'Away' },    { id: 4, name: 'Quentin Gómez', city: 'Tōkyō', country: 'Japan', balance: 5444.92, status: 'Active' },    { id: 5, name: 'Hanna Mendes', city: 'Reykjavík', country: 'Iceland', balance: 16745.23, status: 'Away' },    { id: 6, name: 'Wiktor Lima', city: 'Kraków', country: 'Poland', balance: 6428.86, status: 'Active' },    { id: 7, name: 'Bruno Rocha', city: 'Tōkyō', country: 'Japan', balance: 8297.1, status: 'Active' },    { id: 8, name: 'Gustavo Rocha', city: 'Québec', country: 'Canada', balance: 10280.87, status: 'Active' }]; const { config } = useVitral();const money = computed(() => new Intl.NumberFormat(config.locale.code, { style: 'currency', currency: 'EUR' })); // What the reader has done to the columns: an application would store this.const layout = ref<ColumnLayoutLike | null>(null);const layoutSummary = computed(() => {    const it = layout.value;    if (!it) return 'Nothing changed yet.';    const parts = [        it.order?.length ? `order: ${it.order.join(', ')}` : '',        it.hidden?.length ? `hidden: ${it.hidden.join(', ')}` : '',        Object.keys(it.widths ?? {}).length ? `${Object.keys(it.widths!).length} widths` : ''    ].filter(Boolean);    return parts.length ? parts.join(' · ') : 'Nothing changed yet.';});</script> <template>    <StackPanel spacing="1rem" style="width: 100%">        <DataGrid            v-model:column-layout="layout"            :value="people"            data-key="id"            aria-label="People, arranged"            resizable-columns            reorderable-columns            column-toggle            scrollable            size="small"            show-gridlines            class="demo-table"            style="max-width: 100%"        >            <Column field="name" header="Name" sortable pinned="left" :width="180" />            <Column field="country" header="Country" sortable :width="140" />            <Column field="city" header="City" sortable :width="160" />            <Column field="role" header="Role" :width="150" />            <Column field="status" header="Status" :width="120" />            <Column field="balance" header="Balance" align="right" pinned="right" :width="130">                <template #body="{ data }">{{ money.format(data.balance) }}</template>            </Column>        </DataGrid>        <div class="demo-table-bar">            <Button size="small" severity="secondary" variant="outlined" @click="layout = null">Reset the layout</Button>            <small style="color: var(--vt-text-muted-color)">{{ layoutSummary }}</small>        </div>    </StackPanel></template> <style scoped>.demo-table {    width: 100%;} .demo-table-bar {    display: flex;    align-items: center;    gap: 0.75rem;    flex-wrap: wrap;}</style>

Two tables, one set of columns

`group` is a name two tables share: rearrange a column in either and both follow, and scrolling one sideways scrolls the other. The rows are each table's own — here, this quarter above last quarter.

NameCountryCityBalance
Ximena DialloBrazilSão Paulo
€4,586.25
Noémie NowakSwitzerlandZürich
€5,484.63
Bruno NowakPolandKraków
€456.73
Quentin GómezJapanTōkyō
€5,444.92
NameCountryCityBalance
Hanna MendesIcelandReykjavík
€16,745.23
Wiktor LimaPolandKraków
€6,428.86
Bruno RochaJapanTōkyō
€8,297.10
Gustavo RochaCanadaQuébec
€10,280.87
<script setup lang="ts">import { Column, DataGrid, StackPanel, useVitral } from '@vitral/vue';import { computed } from 'vue'; const thisQuarter = [    { id: 1, name: 'Ximena Diallo', city: 'São Paulo', country: 'Brazil', balance: 4586.25 },    { id: 2, name: 'Noémie Nowak', city: 'Zürich', country: 'Switzerland', balance: 5484.63 },    { id: 3, name: 'Bruno Nowak', city: 'Kraków', country: 'Poland', balance: 456.73 },    { id: 4, name: 'Quentin Gómez', city: 'Tōkyō', country: 'Japan', balance: 5444.92 }];const lastQuarter = [    { id: 5, name: 'Hanna Mendes', city: 'Reykjavík', country: 'Iceland', balance: 16745.23 },    { id: 6, name: 'Wiktor Lima', city: 'Kraków', country: 'Poland', balance: 6428.86 },    { id: 7, name: 'Bruno Rocha', city: 'Tōkyō', country: 'Japan', balance: 8297.1 },    { id: 8, name: 'Gustavo Rocha', city: 'Québec', country: 'Canada', balance: 10280.87 }]; const { config } = useVitral();const money = computed(() => new Intl.NumberFormat(config.locale.code, { style: 'currency', currency: 'EUR' }));</script> <template>    <StackPanel spacing="0.5rem" style="width: 100%">        <DataGrid :value="thisQuarter" data-key="id" aria-label="This quarter" group="quarters" resizable-columns reorderable-columns size="small" scrollable class="demo-table">            <Column field="name" header="Name" :width="170" />            <Column field="country" header="Country" :width="140" />            <Column field="city" header="City" :width="150" />            <Column field="balance" header="Balance" align="right" :width="130">                <template #body="{ data }">{{ money.format(data.balance) }}</template>            </Column>        </DataGrid>        <DataGrid :value="lastQuarter" data-key="id" aria-label="Last quarter" group="quarters" resizable-columns reorderable-columns size="small" scrollable class="demo-table">            <Column field="name" header="Name" :width="170" />            <Column field="country" header="Country" :width="140" />            <Column field="city" header="City" :width="150" />            <Column field="balance" header="Balance" align="right" :width="130">                <template #body="{ data }">{{ money.format(data.balance) }}</template>            </Column>        </DataGrid>    </StackPanel></template> <style scoped>.demo-table {    width: 100%;}</style>

Grouped, the way a base is read

`group-by` gathers the rows by a field and puts a heading over each run, with its count and a toggle. It happens after the query, so sorting and filtering still decide which rows there are — and the rows are gathered by value rather than by adjacency, so sorting by another column keeps each group together and sorts inside it. `v-model:collapsedGroups` is what the reader shut, which an application can store.

0 shut
People by group
Ana EtxeberriaLisboaPortugal
Active
€7,618.88
Fatou FerreiraÉvoraPortugal
Offline
€12,014.06
Fatou JónssonLisboaPortugal
Offline
€10,703.89
Raquel DialloLisboaPortugal
Offline
-€493.76
Ana GómezRecifeBrazil
Active
€11,750.51
Bruno LimaSão PauloBrazil
Away
€12,562.40
Bruno MartinFlorianópolisBrazil
Active
€17,876.33
Bruno SouzaFlorianópolisBrazil
Active
€13,363.03
Élise DuboisSão PauloBrazil
Active
€14,269.79
Fatou FerreiraSão PauloBrazil
Blocked
€10,543.41
Oskar MartinSão PauloBrazil
Away
€10,400.34
Ximena DialloSão PauloBrazil
Offline
€4,586.25
Ana JónssonBogotáColombia
Offline
€4,625.47
Valéria FontaineBogotáColombia
Away
€7,948.90
Ximena SouzaBogotáColombia
Offline
€9,305.62
Ana LimaMünchenGermany
Active
€15,860.46
Carla LimaMünchenGermany
Away
€2,002.67
Hanna MartinMünchenGermany
Blocked
€7,969.50
João EtxeberriaKölnGermany
Active
€3,947.82
Kenji CostaKölnGermany
Active
€11,732.89
Mateus DuboisKölnGermany
Away
€3,976.73
Bruno LimaMálagaSpain
Active
€17,131.64
Fatou GómezMálagaSpain
Blocked
€755.90
João NowakMálagaSpain
Active
€4,612.00
Kenji NowakMálagaSpain
Active
-€776.43
Noémie SatoMálagaSpain
Active
€9,951.09
Bruno NowakKrakówPoland
Away
€456.73
Iñaki DuboisKrakówPoland
Blocked
€17,709.64
João GómezKrakówPoland
Away
€12,230.50
Wiktor CostaKrakówPoland
Blocked
€17,144.37
Wiktor CostaKrakówPoland
Blocked
€504.85
Wiktor FerreiraKrakówPoland
Active
€1,503.44
Wiktor LimaKrakówPoland
Active
€6,428.86
Wiktor SouzaKrakówPoland
Active
-€303.08
Bruno RochaTōkyōJapan
Active
€8,297.10
Carla DialloTōkyōJapan
Active
€5,945.52
Fatou RochaTōkyōJapan
Blocked
-€1,940.60
João MartinTōkyōJapan
Away
€5,028.76
Kenji CostaTōkyōJapan
Offline
€8,098.99
Quentin GómezTōkyōJapan
Active
€5,444.92
Valéria ÁlvarezTōkyōJapan
Offline
€2,964.27
Wiktor CostaTōkyōJapan
Active
-€1,790.27
Bruno RochaMontréalCanada
Offline
€17,103.69
Élise SatoQuébecCanada
Offline
€4,759.90
Gustavo RochaQuébecCanada
Active
€10,280.87
Hanna SouzaQuébecCanada
Blocked
€2,684.86
João EtxeberriaMontréalCanada
Away
€11,302.35
Wiktor JónssonQuébecCanada
Active
€10,857.06
Carla FontaineZürichSwitzerland
Offline
€15,325.86
Élise LimaZürichSwitzerland
Active
€8,895.92
Noémie NowakZürichSwitzerland
Offline
€5,484.63
Søren MüllerZürichSwitzerland
Away
€11,236.83
Wiktor LimaZürichSwitzerland
Active
€1,071.43
Ximena EtxeberriaZürichSwitzerland
Away
€11,252.24
Zoë MendesZürichSwitzerland
Away
€2,202.54
Diego CostaReykjavíkIceland
Offline
€13,914.59
Fatou FontaineReykjavíkIceland
Away
€13,839.42
Hanna MendesReykjavíkIceland
Away
€16,745.23
Tomás DuboisReykjavíkIceland
Active
€12,242.07
Tomás FerreiraReykjavíkIceland
Offline
€8,764.27
Wiktor FontaineReykjavíkIceland
Away
€13,239.60
Diego FerreiraCórdobaArgentina
Away
€15,492.29
Noémie FontaineCórdobaArgentina
Away
€9,472.17
Ximena FerreiraCórdobaArgentina
Active
€2,865.18
<script setup lang="ts">import { Button, Column, DataGrid, Icon, InputText, SelectButton, useVitral } from '@vitral/vue';import { computed, onBeforeUnmount, onMounted, ref } from 'vue'; type Status = 'Active' | 'Away' | 'Offline' | 'Blocked'; interface Person {    id: number;    name: string;    city: string;    country: string;    joined: Date;    balance: number;    status: Status;} // A small deterministic generator, so the demo shows the same people every time.let seed = 7;const random = () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646;const pick = <T,>(list: readonly T[]) => list[Math.floor(random() * list.length)]!; const firstNames = ['Ana', 'Bruno', 'Carla', 'Diego', 'Élise', 'Fatou', 'Gustavo', 'Hanna', 'Iñaki', 'João', 'Kenji', 'Lucía', 'Mateus', 'Noémie', 'Oskar', 'Paula', 'Quentin', 'Raquel', 'Søren', 'Tomás', 'Valéria', 'Wiktor', 'Ximena', 'Zoë'];const lastNames = ['Souza', 'Lima', 'Mendes', 'Ferreira', 'Martin', 'Diallo', 'Rocha', 'Müller', 'Etxeberria', 'Pereira', 'Sato', 'Gómez', 'Nowak', 'Dubois', 'Jónsson', 'Costa', 'Fontaine', 'Álvarez'];const places: [string, string][] = [    ['São Paulo', 'Brazil'],    ['Recife', 'Brazil'],    ['Florianópolis', 'Brazil'],    ['Lisboa', 'Portugal'],    ['Évora', 'Portugal'],    ['Montréal', 'Canada'],    ['Québec', 'Canada'],    ['München', 'Germany'],    ['Köln', 'Germany'],    ['Córdoba', 'Argentina'],    ['Bogotá', 'Colombia'],    ['Kraków', 'Poland'],    ['Zürich', 'Switzerland'],    ['Reykjavík', 'Iceland'],    ['Tōkyō', 'Japan'],    ['Málaga', 'Spain']];const statuses: Status[] = ['Active', 'Active', 'Active', 'Away', 'Offline', 'Blocked']; const people: Person[] = Array.from({ length: 64 }, (_, i) => {    const [city, country] = pick(places);    return {        id: i + 1,        name: `${pick(firstNames)} ${pick(lastNames)}`,        city,        country,        joined: new Date(2019 + Math.floor(random() * 7), Math.floor(random() * 12), 1 + Math.floor(random() * 28)),        balance: Math.round(random() * 2_000_000) / 100 - 2000,        status: pick(statuses)    };}); const { config } = useVitral();const money = computed(() => new Intl.NumberFormat(config.locale.code, { style: 'currency', currency: 'EUR' })); const groupField = ref<'country' | 'status'>('country');const collapsed = ref<string[]>([]);const boardFilters = ref({ global: { value: null as string | null, matchMode: 'contains' } });const board = ref<HTMLElement | null>(null);const wide = ref(false); /** The browser's own full screen, so the grid gets the whole display and nothing else. */async function toggleWide() {    if (!document.fullscreenElement) await board.value?.requestFullscreen?.().catch(() => {});    else await document.exitFullscreen().catch(() => {});}const onFullscreen = () => (wide.value = !!document.fullscreenElement);onMounted(() => document.addEventListener('fullscreenchange', onFullscreen));onBeforeUnmount(() => document.removeEventListener('fullscreenchange', onFullscreen));</script> <template>    <div ref="board" style="display: flex; flex-direction: column; gap: 1rem; width: 100%; background: var(--vt-content-background)">        <div class="demo-table-bar">            <SelectButton v-model="groupField" :options="[{ label: 'By country', value: 'country' }, { label: 'By status', value: 'status' }]" option-label="label" option-value="value" size="small" aria-label="Group by" />            <InputText v-model="boardFilters.global.value" placeholder="Search" aria-label="Search rows" size="small" clearable class="demo-table-search">                <template #prefix><Icon icon="search" /></template>            </InputText>            <small style="color: var(--vt-text-muted-color)">{{ collapsed.length }} shut</small>            <Button :label="wide ? 'Close' : 'Full screen'" :icon="wide ? 'restore' : 'maximize'" severity="secondary" variant="outlined" size="small" @click="toggleWide" />        </div>        <DataGrid            v-model:filters="boardFilters"            v-model:collapsed-groups="collapsed"            :value="people"            :group-by="groupField"            :group-label="({ value, count }) => `${value} · ${count}`"            data-key="id"            caption="People by group"            :global-filter-fields="['name', 'city', 'country']"            sort-field="name"            :sort-order="1"            removable-sort            show-gridlines            scrollable            :scroll-height="wide ? 'calc(100vh - 8rem)' : '22rem'"            class="demo-table"        >            <Column field="name" header="Name" sortable />            <Column field="city" header="City" sortable />            <Column field="country" header="Country" sortable />            <Column field="status" header="Status" sortable>                <template #body="{ data }">                    <span class="demo-status"><span :class="['demo-dot', `demo-dot-${data.status.toLowerCase()}`]" aria-hidden="true" />{{ data.status }}</span>                </template>            </Column>            <Column field="balance" header="Balance" sortable align="right">                <template #body="{ data }">{{ money.format(data.balance) }}</template>            </Column>        </DataGrid>    </div></template> <style scoped>.demo-table {    width: 100%;} .demo-table-bar {    display: flex;    align-items: center;    gap: 0.75rem;    flex-wrap: wrap;} .demo-table-search {    margin-inline-start: auto;    width: 16rem;} .demo-status {    display: inline-flex;    align-items: center;    gap: 0.5rem;} .demo-dot {    width: 0.5rem;    height: 0.5rem;    border-radius: 999px;    background: var(--vt-text-muted-color);} .demo-dot-active {    background: var(--vt-success-color);} .demo-dot-away {    background: var(--vt-warn-color);} .demo-dot-blocked {    background: var(--vt-danger-color);}</style>

From a data source

The rows come from `createDataSource({ load })`, a stand-in for a server that answers in 450 ms. The table asks it for one page at a time with the sort and the search; a slow answer that arrives after a newer one is thrown away.

Server1 requests
No available options
Loading…
<script setup lang="ts">import { queryData } from '@vitral/core';import { Column, createDataSource, DataGrid, Icon, InputText, type LoadOptions } from '@vitral/vue';import { ref } from 'vue'; type Status = 'Active' | 'Away' | 'Offline' | 'Blocked'; interface Person {    id: number;    name: string;    city: string;    country: string;    joined: Date;    balance: number;    status: Status;} // A small deterministic generator, so the demo shows the same people every time.let seed = 7;const random = () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646;const pick = <T,>(list: readonly T[]) => list[Math.floor(random() * list.length)]!; const firstNames = ['Ana', 'Bruno', 'Carla', 'Diego', 'Élise', 'Fatou', 'Gustavo', 'Hanna', 'Iñaki', 'João', 'Kenji', 'Lucía', 'Mateus', 'Noémie', 'Oskar', 'Paula', 'Quentin', 'Raquel', 'Søren', 'Tomás', 'Valéria', 'Wiktor', 'Ximena', 'Zoë'];const lastNames = ['Souza', 'Lima', 'Mendes', 'Ferreira', 'Martin', 'Diallo', 'Rocha', 'Müller', 'Etxeberria', 'Pereira', 'Sato', 'Gómez', 'Nowak', 'Dubois', 'Jónsson', 'Costa', 'Fontaine', 'Álvarez'];const places: [string, string][] = [    ['São Paulo', 'Brazil'],    ['Recife', 'Brazil'],    ['Florianópolis', 'Brazil'],    ['Lisboa', 'Portugal'],    ['Évora', 'Portugal'],    ['Montréal', 'Canada'],    ['Québec', 'Canada'],    ['München', 'Germany'],    ['Köln', 'Germany'],    ['Córdoba', 'Argentina'],    ['Bogotá', 'Colombia'],    ['Kraków', 'Poland'],    ['Zürich', 'Switzerland'],    ['Reykjavík', 'Iceland'],    ['Tōkyō', 'Japan'],    ['Málaga', 'Spain']];const statuses: Status[] = ['Active', 'Active', 'Active', 'Away', 'Offline', 'Blocked']; const people: Person[] = Array.from({ length: 64 }, (_, i) => {    const [city, country] = pick(places);    return {        id: i + 1,        name: `${pick(firstNames)} ${pick(lastNames)}`,        city,        country,        joined: new Date(2019 + Math.floor(random() * 7), Math.floor(random() * 12), 1 + Math.floor(random() * 28)),        balance: Math.round(random() * 2_000_000) / 100 - 2000,        status: pick(statuses)    };}); // A stand-in for a server: it answers in 450 ms with one page, sorted and searched.const requests = ref(0);const remote = createDataSource<Person>({    load: async (options: LoadOptions) => {        requests.value++;        await new Promise((resolve) => setTimeout(resolve, 450));        return queryData(people, options);    }});const remoteFilters = ref({ global: { value: null as string | null, matchMode: 'contains' } });</script> <template>    <DataGrid :data-source="remote" v-model:filters="remoteFilters" :global-filter-fields="['name', 'city', 'country']" aria-label="Remote people" paginator :rows="6" :filter-delay="250" class="demo-table">        <template #header>            <div class="demo-table-bar">                <strong>Server</strong>                <small style="color: var(--vt-text-muted-color)">{{ requests }} requests</small>                <InputText v-model="remoteFilters.global.value" placeholder="Search" aria-label="Search the server" size="small" class="demo-table-search">                    <template #prefix><Icon icon="search" /></template>                </InputText>            </div>        </template>        <Column field="name" header="Name" sortable />        <Column field="city" header="City" sortable />        <Column field="status" header="Status" sortable>            <template #body="{ data }">                <span class="demo-status"><span :class="['demo-dot', `demo-dot-${data.status.toLowerCase()}`]" aria-hidden="true" />{{ data.status }}</span>            </template>        </Column>    </DataGrid></template> <style scoped>.demo-table {    width: 100%;} .demo-table-bar {    display: flex;    align-items: center;    gap: 0.75rem;    flex-wrap: wrap;} .demo-table-search {    margin-inline-start: auto;    width: 16rem;} .demo-status {    display: inline-flex;    align-items: center;    gap: 0.5rem;} .demo-dot {    width: 0.5rem;    height: 0.5rem;    border-radius: 999px;    background: var(--vt-text-muted-color);} .demo-dot-active {    background: var(--vt-success-color);} .demo-dot-away {    background: var(--vt-warn-color);} .demo-dot-blocked {    background: var(--vt-danger-color);}</style>

Empty and loading

InvoiceAmount
No invoices this month
NameCity
Ximena DialloSão Paulo
Noémie NowakZürich
Bruno NowakKraków
Quentin GómezTōkyō
Loading…
<script setup lang="ts">import { Column, DataGrid } from '@vitral/vue'; const people = [    { name: 'Ximena Diallo', city: 'São Paulo' },    { name: 'Noémie Nowak', city: 'Zürich' },    { name: 'Bruno Nowak', city: 'Kraków' },    { name: 'Quentin Gómez', city: 'Tōkyō' }];</script> <template>    <DataGrid :value="[]" aria-label="Empty table" empty-message="No invoices this month" class="demo-table demo-table-half">        <Column field="number" header="Invoice" />        <Column field="amount" header="Amount" align="right" />    </DataGrid>    <DataGrid :value="people" aria-label="Loading table" loading class="demo-table demo-table-half">        <Column field="name" header="Name" />        <Column field="city" header="City" />    </DataGrid></template> <style scoped>.demo-table {    width: 100%;} .demo-table-half {    flex: 1 1 20rem;    width: auto;}</style>

API

Read from packages/vue/src/components/DataGrid/types.ts, so it says what the component actually accepts.

Props

NameTypeDescription
groupBystringGathers the rows by this field and puts a heading over each run, with a count and a toggle. Grouping happens after the query, so sorting and filtering still decide which rows there are; `v-model:collapsedGroups` holds which of them the reader has shut.
groupLabel(context: { value: unknown; count: number; rows: any[] }) => stringWhat a group's heading says; the value itself by default.
valueany[]—
dataKeystringA field that identifies a row, for selection and row keys.
dataSourceDataSourceLikeRows come from a data source, local or remote, instead of `value`.
lazybooleanNothing is computed here: sort, filter and page changes are emitted (`lazy-load`) for the app to answer.
totalRecordsnumberThe total across pages when `lazy`.
loadingboolean—
paginatorboolean—
rowsPerPageOptionsnumber[]—
pageLinkSizenumber—
paginatorTemplatestring | ('FirstPageLink' | 'PrevPageLink' | 'PageLinks' | 'NextPageLink' | 'LastPageLink' | 'RowsPerPageDropdown' | 'CurrentPageReport')[]—
currentPageReportTemplatestring—
paginatorPosition'top' | 'bottom'—
alwaysShowPaginatorboolean—
sortMode'single' | 'multiple'—
removableSortbooleanA third click on a sorted column takes it out of the sort.
globalFilterFieldsstring[]—
filterDisplay'row'`'row'` puts a filter box under each filterable column's header.
filterDelaynumberMilliseconds to wait after typing before a lazy table or a data source is asked again.
selectionMode'single' | 'multiple'—
resizableColumnsbooleanA handle on every column's trailing edge, dragged (or keyed) to set its width.
columnResizeMode'fit' | 'expand'`'fit'` takes the width from the next column, keeping the table's own; `'expand'` widens the table.
reorderableColumnsbooleanColumns can be dragged by their header into another order, or moved with Ctrl and an arrow key.
columnTogglebooleanA button that opens the list of columns, each with a checkbox.
groupstringA name two tables share: they scroll sideways together and keep one column layout.
stripedRowsboolean—
showGridlinesboolean—
sizeSize—
scrollableboolean—
scrollHeightstringA CSS height, or `'flex'` to fill a flex parent. The header stays in view.
emptyMessagestring—
rowClass(data: any) => unknown—
tableStylestring | Record<string, string>—
captionstringNames the table. Hidden unless `showCaption`; without it, name the table with `aria-label`.
showCaptionboolean—

Plus pt, dt and unstyled from BaseProps, see pass-through and unstyled mode.

Emits

EventPayloadDescription
pageevent: DataGridPageEvent—
column-resizeevent: DataGridColumnEvent & { width: number }A column was widened or narrowed.
column-reorderevent: DataGridColumnEvent & { order: string[] }A column was moved.
column-toggleevent: DataGridColumnEvent & { visible: boolean }A column was shown or hidden.
column-pinevent: DataGridColumnEvent & { side: 'left' | 'right' | null }A column was stuck to an edge, or let go.
sortevent: DataGridSortEvent—
filterevent: DataGridFilterEvent—
lazy-loadevent: LoadOptionsLike—
row-clickevent: DataGridRowClickEvent—
row-selectevent: DataGridRowSelectEvent—
row-unselectevent: DataGridRowSelectEvent—
row-select-allevent: DataGridSelectAllEvent—
row-unselect-allevent: DataGridSelectAllEvent—

Slots

NameSlot propsDescription
default—The `<Column>`s.
header——
footer——
empty——
loadingicon——
paginatorstart(props: DataGridPageEvent)—
paginatorend(props: DataGridPageEvent)—