BambitTestTask/pages/index.vue
2025-11-18 15:11:55 +03:00

346 lines
8.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="index-page">
<div class="index-page__search">
<UInput
:model-value="search"
size="xl"
class="w-full"
placeholder="Поиск..."
:loading="postsIsLoading || usersIsLoading"
@update:model-value="updateSearch"
/>
</div>
<div
ref="tableContainerRef"
class="table-wrapper"
>
<div :style="{ height: `${totalSize}px` }">
<!-- Even though we're still using sematic table tags, we must use CSS grid and flexbox for dynamic row heights -->
<table>
<thead>
<tr
v-for="headerGroup in table?.getHeaderGroups()"
:key="headerGroup?.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
: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] }}
</th>
</tr>
</thead>
<tbody :style="{ height: `${totalSize}px` }">
<tr
v-for="vRow in virtualRows"
:ref="measureElement /*measure dynamic row height*/"
:key="rows[vRow?.index]?.id"
:data-index=" vRow?.index /* needed for dynamic row height measurement*/ "
:style="{ transform: `translateY(${vRow?.start}px)` }"
>
<td
v-for="cell in rows[vRow?.index]?.getVisibleCells()"
:key="cell?.id"
:style="[cell.column.columnDef.meta?.style, isMobile ? { width: '100%' } : { width: `${cell.column.getSize()}px` }]"
>
<FlexRender
:render="cell?.column?.columnDef?.cell"
:props="cell?.getContext()"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<UModal v-model:open="open" :title="selectedUser?.email">
<template #body>
<div>
<div>Имя: {{ selectedUser?.name }}</div>
<div>Логин: {{ selectedUser?.username }}</div>
<div>Электронная почта: {{ selectedUser?.email }}</div>
<div>Телефон: {{ selectedUser?.phone }}</div>
<a :href="`https://${selectedUser?.website}`" target="_blank">Веб-сайт: <span class="title-cell">{{ selectedUser?.website }}</span></a>
<div>Название компании: {{ selectedUser?.company?.name }}</div>
<div>Адрес: {{ `${selectedUser?.address?.street} ${selectedUser?.address?.suite} ${selectedUser?.address?.city}` }}</div>
</div>
</template>
</UModal>
</div>
</template>
<script setup lang="ts">
import { UTooltip } from '#components'
import {
createColumnHelper,
FlexRender,
getCoreRowModel,
getSortedRowModel,
useVueTable,
} from '@tanstack/vue-table'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useDebounceFn, useMediaQuery } from '@vueuse/core'
import { useGetPosts, useGetUsers } from '~/api/queries'
const isMobile = useMediaQuery('(max-width: 1280px)')
const open = ref(false)
const selectedUser = ref<User | null>(null)
const search = ref('')
const updateSearch = useDebounceFn((value: string) => {
search.value = value
}, 500)
const { data: posts, isLoading: postsIsLoading } = useGetPosts(search)
const { data: users, isLoading: usersIsLoading } = useGetUsers()
const tableData = computed(() =>
posts?.value?.map(post => ({
userId: post?.userId,
userEmail: users?.value?.find(user => user?.id === post?.userId)?.email,
id: post?.id,
title: post?.title,
body: post?.body,
})))
const columnHelper = createColumnHelper<Posts>()
const columns = [
columnHelper.accessor('id', {
header: 'ID',
footer: props => props?.column?.id,
meta: {
style: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
},
size: 50,
}),
columnHelper.accessor('title', {
header: 'Заголовок',
cell: info => isMobile.value
? info.getValue()
: h(UTooltip, { text: info.getValue(),
}, {
default: () => h('span', { class: 'ellipsis' }, truncate(info.getValue()),
),
}),
size: 150,
}),
columnHelper.accessor('userEmail', {
header: 'Автор',
cell: info => isMobile.value
? info.getValue()
: h(UTooltip, { text: info.getValue(),
}, {
default: () => h('div', { class: 'title-cell', onClick: () => openPost(info?.row?.original) }, truncate(info.getValue()),
),
}),
meta: {
style: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
},
size: 170,
}),
columnHelper.accessor('body', {
header: 'Контент',
cell: info => isMobile.value
? info.getValue()
: h(UTooltip, {
text: info.getValue(),
}, {
default: () => h('span', { class: 'ellipsis' }, truncate(info.getValue(), 22)),
}),
size: 220,
}),
]
const table = useVueTable({
get data() {
return tableData
},
columns,
getCoreRowModel: getCoreRowModel(),
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) {
selectedUser.value = users?.value?.find(user => user?.id === post?.userId)
open.value = true
}
function truncate(text: string, max = 15) {
return text?.length > max ? `${text?.slice(0, max)}` : text
}
</script>
<style lang="scss">
@use '~/assets/scss/utils' as *;
.index-page {
display: flex;
flex-direction: column;
gap: 20px;
max-width: 600px;
margin-inline: auto;
padding-bottom: 40px;
position: relative;
&__search {
position: fixed;
top: 48px;
left: 50%;
transform: translateX(-50%);
max-width: 600px;
width: 100%;
z-index: 1000;
background: var(--ui-bg-base);
@include mobile {
padding-inline: 10px;
}
}
}
/* -------------------- TABLE WRAPPER -------------------- */
.table-wrapper {
position: relative;
margin-top: calc(40px + 10px);
border-radius: 14px;
overflow-y: auto;
height: 600px;
scrollbar-width: thin;
@include mobile {
height: 500px;
}
}
table {
min-width: 100%;
border-collapse: separate;
border-spacing: 0;
background: var(--ui-bg-elevated);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
white-space: nowrap;
}
/* -------------------- HEADER -------------------- */
thead {
display: grid;
position: sticky;
top: 0;
z-index: 1;
}
thead tr {
display: flex;
width: 100%;
}
thead th {
background: var(--ui-bg-accented);
color: var(--ui-text);
padding: 12px 14px;
border-bottom: 1px solid var(--ui-border);
user-select: none;
transition: background 0.15s ease;
}
thead th:hover {
cursor: pointer;
}
/* -------------------- BODY -------------------- */
tbody {
display: grid;
position: relative;
}
tbody tr {
display: flex;
position: absolute;
width: 100%;
height: 50px;
transition: background 0.2s ease;
}
tbody tr:nth-child(odd) {
background: var(--ui-bg-subtle);
}
tbody tr:hover {
background: var(--ui-bg-accented);
}
tbody td {
padding: 10px 12px;
border-bottom: 1px solid var(--ui-border-subtle);
}
tbody tr:last-child td {
border-bottom: none;
}
/* -------------------- CLICKABLE USER EMAIL CELL -------------------- */
.title-cell {
cursor: pointer;
text-decoration: underline;
color: var(--ui-primary);
}
.title-cell:hover {
color: var(--ui-primary-hover);
}
</style>