This commit is contained in:
alsaze 2025-11-18 15:11:55 +03:00
parent e479976fb3
commit 93dcb3916f
3 changed files with 138 additions and 69 deletions

View File

@ -4,7 +4,7 @@ import { getUsers } from '~/api/endpoints'
export const useGetUsers = () => { export const useGetUsers = () => {
return useQuery<Users>({ return useQuery<Users>({
queryKey: ['get-users'], queryKey: ['get-users'],
queryFn: getUsers, queryFn: () => getUsers(),
staleTime: Infinity, staleTime: Infinity,
}) })
} }

View File

@ -20,8 +20,9 @@
"@nuxt/ui": "^4.0.1", "@nuxt/ui": "^4.0.1",
"@nuxtjs/i18n": "^10.0.4", "@nuxtjs/i18n": "^10.0.4",
"@tanstack/vue-query": "^5.75.5", "@tanstack/vue-query": "^5.75.5",
"@tanstack/vue-table": "^8.21.3",
"@tanstack/vue-query-devtools": "^5.87.1", "@tanstack/vue-query-devtools": "^5.87.1",
"@tanstack/vue-table": "^8.21.3",
"@tanstack/vue-virtual": "^3.13.12",
"@vee-validate/nuxt": "^4.15.1", "@vee-validate/nuxt": "^4.15.1",
"@vueuse/core": "^13.1.0", "@vueuse/core": "^13.1.0",
"axios": "^1.12.2", "axios": "^1.12.2",

View File

@ -11,49 +11,58 @@
/> />
</div> </div>
<div class="table-wrapper"> <div
<table> ref="tableContainerRef"
<thead> class="table-wrapper"
<tr >
v-for="headerGroup in table?.getHeaderGroups()" <div :style="{ height: `${totalSize}px` }">
:key="headerGroup?.id" <!-- Even though we're still using sematic table tags, we must use CSS grid and flexbox for dynamic row heights -->
> <table>
<th <thead>
v-for="header in headerGroup?.headers" <tr
:key="header?.id" v-for="headerGroup in table?.getHeaderGroups()"
:colSpan="header?.colSpan" :key="headerGroup?.id"
:style="header.column.columnDef.meta?.style"
@click="header?.column?.getToggleSortingHandler()?.($event)"
> >
<FlexRender <th
v-if="!header?.isPlaceholder" v-for="header in headerGroup.headers"
:render="header?.column?.columnDef?.header" :key="header.id"
:props="header?.getContext()" :colspan="header.colSpan"
/> :style="[header.column.columnDef.meta?.style, isMobile ? { width: '100%' } : { width: `${header.getSize()}px` }]"
@click="header?.column?.getToggleSortingHandler()?.($event)"
>
<FlexRender
v-if="!header?.isPlaceholder"
:render="header?.column?.columnDef?.header"
:props="header?.getContext()"
/>
{{ { asc: ' 🔼', desc: ' 🔽' }[header.column.getIsSorted() as string] }} {{ { asc: ' 🔼', desc: ' 🔽' }[header.column.getIsSorted() as string] }}
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody :style="{ height: `${totalSize}px` }">
<tr <tr
v-for="row in table?.getRowModel()?.rows" v-for="vRow in virtualRows"
:key="row?.id" :ref="measureElement /*measure dynamic row height*/"
> :key="rows[vRow?.index]?.id"
<td :data-index=" vRow?.index /* needed for dynamic row height measurement*/ "
v-for="cell in row?.getVisibleCells()" :style="{ transform: `translateY(${vRow?.start}px)` }"
:key="cell?.id"
:style="cell.column.columnDef.meta?.style"
> >
<FlexRender <td
:render="cell?.column?.columnDef?.cell" v-for="cell in rows[vRow?.index]?.getVisibleCells()"
:props="cell?.getContext()" :key="cell?.id"
/> :style="[cell.column.columnDef.meta?.style, isMobile ? { width: '100%' } : { width: `${cell.column.getSize()}px` }]"
</td> >
</tr> <FlexRender
</tbody> :render="cell?.column?.columnDef?.cell"
</table> :props="cell?.getContext()"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div> </div>
<UModal v-model:open="open" :title="selectedUser?.email"> <UModal v-model:open="open" :title="selectedUser?.email">
@ -81,6 +90,7 @@ import {
getSortedRowModel, getSortedRowModel,
useVueTable, useVueTable,
} from '@tanstack/vue-table' } from '@tanstack/vue-table'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useDebounceFn, useMediaQuery } from '@vueuse/core' import { useDebounceFn, useMediaQuery } from '@vueuse/core'
import { useGetPosts, useGetUsers } from '~/api/queries' import { useGetPosts, useGetUsers } from '~/api/queries'
@ -108,7 +118,14 @@ const columns = [
columnHelper.accessor('id', { columnHelper.accessor('id', {
header: 'ID', header: 'ID',
footer: props => props?.column?.id, footer: props => props?.column?.id,
meta: { style: 'text-align: center' }, meta: {
style: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
},
size: 50,
}), }),
columnHelper.accessor('title', { columnHelper.accessor('title', {
header: 'Заголовок', header: 'Заголовок',
@ -119,15 +136,25 @@ const columns = [
default: () => h('span', { class: 'ellipsis' }, truncate(info.getValue()), default: () => h('span', { class: 'ellipsis' }, truncate(info.getValue()),
), ),
}), }),
size: 150,
}), }),
columnHelper.accessor('userEmail', { columnHelper.accessor('userEmail', {
header: 'Автор', header: 'Автор',
cell: info => h( cell: info => isMobile.value
'span', ? info.getValue()
{ class: 'title-cell', onClick: () => openPost(info.row.original) }, : h(UTooltip, { text: info.getValue(),
info.getValue(), }, {
), default: () => h('div', { class: 'title-cell', onClick: () => openPost(info?.row?.original) }, truncate(info.getValue()),
meta: { style: 'text-align: center' }, ),
}),
meta: {
style: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
},
size: 170,
}), }),
columnHelper.accessor('body', { columnHelper.accessor('body', {
header: 'Контент', header: 'Контент',
@ -136,8 +163,9 @@ const columns = [
: h(UTooltip, { : h(UTooltip, {
text: info.getValue(), text: info.getValue(),
}, { }, {
default: () => h('span', { class: 'ellipsis' }, truncate(info.getValue())), default: () => h('span', { class: 'ellipsis' }, truncate(info.getValue(), 22)),
}), }),
size: 220,
}), }),
] ]
@ -150,17 +178,46 @@ const table = useVueTable({
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
}) })
// The virtualizer needs to know the scrollable container element
const rows = computed(() => table?.getRowModel()?.rows)
const tableContainerRef = ref<HTMLDivElement | null>(null)
const rowVirtualizerOptions = computed(() => {
return {
count: rows.value.length,
estimateSize: () => 50, // estimate row height for accurate scrollbar dragging
getScrollElement: () => tableContainerRef.value,
overscan: 5,
}
})
const rowVirtualizer = useVirtualizer(rowVirtualizerOptions)
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems())
const totalSize = computed(() => rowVirtualizer.value.getTotalSize())
function measureElement(el?: Element) {
if (!el) {
return
}
rowVirtualizer.value.measureElement(el)
return undefined
}
// virtualizer end
function openPost(post: Post) { function openPost(post: Post) {
selectedUser.value = users?.value?.find(user => user?.id === post?.userId) selectedUser.value = users?.value?.find(user => user?.id === post?.userId)
open.value = true open.value = true
} }
function truncate(text: string, max = 15) { function truncate(text: string, max = 15) {
return text.length > max ? `${text.slice(0, max)}` : text return text?.length > max ? `${text?.slice(0, max)}` : text
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss">
@use '~/assets/scss/utils' as *; @use '~/assets/scss/utils' as *;
.index-page { .index-page {
@ -191,11 +248,16 @@ function truncate(text: string, max = 15) {
/* -------------------- TABLE WRAPPER -------------------- */ /* -------------------- TABLE WRAPPER -------------------- */
.table-wrapper { .table-wrapper {
position: relative;
margin-top: calc(40px + 10px); margin-top: calc(40px + 10px);
border-radius: 14px; border-radius: 14px;
overflow: auto; overflow-y: auto;
max-height: 600px; height: 600px;
scrollbar-width: thin; scrollbar-width: thin;
@include mobile {
height: 500px;
}
} }
table { table {
@ -210,6 +272,18 @@ table {
/* -------------------- HEADER -------------------- */ /* -------------------- HEADER -------------------- */
thead {
display: grid;
position: sticky;
top: 0;
z-index: 1;
}
thead tr {
display: flex;
width: 100%;
}
thead th { thead th {
background: var(--ui-bg-accented); background: var(--ui-bg-accented);
color: var(--ui-text); color: var(--ui-text);
@ -222,12 +296,20 @@ thead th {
} }
thead th:hover { thead th:hover {
background: var(--ui-bg-accented-hover); cursor: pointer;
} }
/* -------------------- BODY -------------------- */ /* -------------------- BODY -------------------- */
tbody {
display: grid;
position: relative;
}
tbody tr { tbody tr {
display: flex;
position: absolute;
width: 100%;
height: 50px; height: 50px;
transition: background 0.2s ease; transition: background 0.2s ease;
} }
@ -241,8 +323,7 @@ tbody tr:hover {
} }
tbody td { tbody td {
padding: 12px 14px; padding: 10px 12px;
font-size: 14px;
border-bottom: 1px solid var(--ui-border-subtle); border-bottom: 1px solid var(--ui-border-subtle);
} }
@ -250,25 +331,12 @@ tbody tr:last-child td {
border-bottom: none; border-bottom: none;
} }
/* -------------------- SORT ICON -------------------- */
th {
position: relative;
}
th .sort-icon {
margin-left: 6px;
opacity: 0.7;
font-size: 12px;
}
/* -------------------- CLICKABLE USER EMAIL CELL -------------------- */ /* -------------------- CLICKABLE USER EMAIL CELL -------------------- */
.title-cell { .title-cell {
cursor: pointer; cursor: pointer;
text-decoration: underline; text-decoration: underline;
color: var(--ui-primary); color: var(--ui-primary);
font-weight: 500;
} }
.title-cell:hover { .title-cell:hover {