This commit is contained in:
vadim committed 2026-09-25 15:29:51 +03:00
1 parent 4a124a2bb7
commit bb4ddf3f36
3 files changed
+150 -168

No files matched your search

+143 -15
View File
@@ -1,20 +1,148 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { itemsList } from '@/api/user.api.ts'
import type { User } from '@/api/user.api.ts'
interface Props {
selected?: string[]
}
const props = withDefaults(defineProps<Props>(), { selected: () => [] })
const emit = defineEmits<{ changed: [value: string[]] }>()
const PAGE_SIZE = 20
const AVATAR_LIMIT = 5
const users = ref<User[]>([])
const page = ref(0)
const hasMore = ref(true)
const totalCount = ref(0)
const hoveredId = ref<string | null>(null)
const avatarUsers = computed(() => users.value.slice(0, AVATAR_LIMIT))
const extraCount = computed(() => Math.max(totalCount.value - AVATAR_LIMIT, 0))
function initials(user: Pick<User, 'name' | 'email'>) {
return (user.name || user.email || '?')[0]!.toUpperCase()
}
function isSelected(id: string) {
return props.selected.includes(id)
}
function toggle(id: string) {
const next = isSelected(id) ? props.selected.filter((el) => el !== id) : [...props.selected, id]
emit('changed', next)
}
function onMenuSelect(value: unknown) {
if (Array.isArray(value)) {
emit('changed', value.filter((el): el is string => typeof el === 'string'))
}
}
async function loadMore({ done }: { done: (status: 'ok' | 'empty' | 'error') => void }) {
if (!hasMore.value) {
done('empty')
return
}
try {
const nextPage = page.value + 1
const res = await itemsList({ page: nextPage, limit: PAGE_SIZE })
users.value.push(...res.list)
page.value = nextPage
totalCount.value = res.meta.totalCount
hasMore.value = nextPage < res.meta.totalPages
done(hasMore.value ? 'ok' : 'empty')
} catch {
done('error')
}
}
onMounted(() => {
loadMore({ done: () => {} })
})
</script>
<template>
<v-select
variant="outlined"
clearable
:items="[
{ id: 1, image: 'A' },
{ id: 2, image: 'B' },
{ id: 3, image: 'C' },
{ id: 4, image: 'D' },
]"
<div class="d-flex align-center">
<v-avatar
v-for="(user, index) in avatarUsers"
:key="user.id"
rounded="circle"
border
density="compact"
:image="user.image ?? undefined"
:text="initials(user)"
class="stack-avatar cursor-pointer"
:class="{ 'bg-primary': isSelected(user.id) }"
:style="{ zIndex: hoveredId === user.id ? avatarUsers.length + 1 : avatarUsers.length - index }"
@mouseenter="hoveredId = user.id"
@mouseleave="hoveredId = null"
@click="toggle(user.id)"
/>
<v-menu>
<template v-slot:activator="{ props: activatorProps }">
<v-avatar
rounded="circle"
border
density="compact"
variant="tonal"
class="stack-avatar cursor-pointer"
:style="{ zIndex: hoveredId === 'more' ? avatarUsers.length + 1 : 0 }"
v-bind="activatorProps"
@mouseenter="hoveredId = 'more'"
@mouseleave="hoveredId = null"
>
<template #item>
<div>a</div>
</template>
</v-select>
<span v-if="extraCount > 0" class="text-caption">+{{ extraCount }}</span>
<v-icon v-else icon="mdi-dots-horizontal" size="small" />
</v-avatar>
</template>
<style scoped lang="scss"></style>
<v-infinite-scroll height="16rem" @load="loadMore">
<v-list
border
width="12rem"
class="mt-1"
select-strategy="leaf"
density="compact"
:selected="selected"
@update:selected="onMenuSelect"
>
<v-list-item v-for="user in users" :key="user.id" :value="user.id">
<div class="d-flex align-center ga-2">
<v-avatar
:image="user.image ?? undefined"
:text="initials(user)"
rounded="circle"
density="compact"
border
color="primary"
/>
<div>{{ user.name || user.email }}</div>
</div>
</v-list-item>
</v-list>
<template #empty />
</v-infinite-scroll>
</v-menu>
</div>
</template>
<style scoped lang="scss">
.stack-avatar {
position: relative;
transition: transform 0.15s ease;
&:not(:first-child) {
margin-left: -0.75rem;
}
&:hover {
transform: translateY(-0.25rem);
}
}
</style>
-148
View File
@@ -1,148 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { itemsList } from '@/api/user.api.ts'
import type { User } from '@/api/user.api.ts'
interface Props {
selected?: string[]
}
const props = withDefaults(defineProps<Props>(), { selected: () => [] })
const emit = defineEmits<{ changed: [value: string[]] }>()
const PAGE_SIZE = 20
const AVATAR_LIMIT = 5
const users = ref<User[]>([])
const page = ref(0)
const hasMore = ref(true)
const totalCount = ref(0)
const hoveredId = ref<string | null>(null)
const avatarUsers = computed(() => users.value.slice(0, AVATAR_LIMIT))
const extraCount = computed(() => Math.max(totalCount.value - AVATAR_LIMIT, 0))
function initials(user: Pick<User, 'name' | 'email'>) {
return (user.name || user.email || '?')[0]!.toUpperCase()
}
function isSelected(id: string) {
return props.selected.includes(id)
}
function toggle(id: string) {
const next = isSelected(id) ? props.selected.filter((el) => el !== id) : [...props.selected, id]
emit('changed', next)
}
function onMenuSelect(value: unknown) {
if (Array.isArray(value)) {
emit('changed', value.filter((el): el is string => typeof el === 'string'))
}
}
async function loadMore({ done }: { done: (status: 'ok' | 'empty' | 'error') => void }) {
if (!hasMore.value) {
done('empty')
return
}
try {
const nextPage = page.value + 1
const res = await itemsList({ page: nextPage, limit: PAGE_SIZE })
users.value.push(...res.list)
page.value = nextPage
totalCount.value = res.meta.totalCount
hasMore.value = nextPage < res.meta.totalPages
done(hasMore.value ? 'ok' : 'empty')
} catch {
done('error')
}
}
onMounted(() => {
loadMore({ done: () => {} })
})
</script>
<template>
<div class="d-flex align-center">
<v-avatar
v-for="(user, index) in avatarUsers"
:key="user.id"
rounded="circle"
border
density="compact"
:image="user.image ?? undefined"
:text="initials(user)"
class="stack-avatar cursor-pointer"
:class="{ 'bg-primary': isSelected(user.id) }"
:style="{ zIndex: hoveredId === user.id ? avatarUsers.length + 1 : avatarUsers.length - index }"
@mouseenter="hoveredId = user.id"
@mouseleave="hoveredId = null"
@click="toggle(user.id)"
/>
<v-menu>
<template v-slot:activator="{ props: activatorProps }">
<v-avatar
rounded="circle"
border
density="compact"
variant="tonal"
class="stack-avatar cursor-pointer"
:style="{ zIndex: hoveredId === 'more' ? avatarUsers.length + 1 : 0 }"
v-bind="activatorProps"
@mouseenter="hoveredId = 'more'"
@mouseleave="hoveredId = null"
>
<span v-if="extraCount > 0" class="text-caption">+{{ extraCount }}</span>
<v-icon v-else icon="mdi-dots-horizontal" size="small" />
</v-avatar>
</template>
<v-infinite-scroll height="16rem" @load="loadMore">
<v-list
border
width="12rem"
class="mt-1"
select-strategy="leaf"
density="compact"
:selected="selected"
@update:selected="onMenuSelect"
>
<v-list-item v-for="user in users" :key="user.id" :value="user.id">
<div class="d-flex align-center ga-2">
<v-avatar
:image="user.image ?? undefined"
:text="initials(user)"
rounded="circle"
density="compact"
border
color="primary"
/>
<div>{{ user.name || user.email }}</div>
</div>
</v-list-item>
</v-list>
<template #empty />
</v-infinite-scroll>
</v-menu>
</div>
</template>
<style scoped lang="scss">
.stack-avatar {
position: relative;
transition: transform 0.15s ease;
&:not(:first-child) {
margin-left: -0.75rem;
}
&:hover {
transform: translateY(-0.25rem);
}
}
</style>
+7 -5
View File
@@ -1,15 +1,17 @@
<script setup lang="ts">
import { ref } from 'vue'
import AssigneeFilter from '@/components/AssigneeFilter.vue'
import AssigneeFilterAvatar from '@/components/AssigneeFilterAvatar.vue'
import TextFilter from '@/components/TextFilter.vue'
function onChanged(value: string[]) {
console.log(value)
}
</script>
<template>
<div class="u-grid2 ga-1 align-center">
<div class="u-grid2 ga-4 align-center">
<text-filter />
<assignee-filter />
<assignee-filter-avatar />
<assignee-filter @changed="onChanged" :selected="['4Jpoz4EgViFrMpl7jQ969mVyX2IFYg5H']" />
<v-btn variant="text">clear filters</v-btn>
</div>
</template>
@@ -17,6 +19,6 @@ import TextFilter from '@/components/TextFilter.vue'
<style scoped lang="scss">
.u-grid2 {
display: grid;
grid-template-columns: 1fr 10rem auto auto;
grid-template-columns: 1fr auto auto;
}
</style>