filter
This commit is contained in:
1 parent
30e507f5cd
commit
4a124a2bb7
10 files changed
+280
-31
No files matched your search
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<v-select
|
||||
variant="outlined"
|
||||
clearable
|
||||
:items="[
|
||||
{ id: 1, image: 'A' },
|
||||
{ id: 2, image: 'B' },
|
||||
{ id: 3, image: 'C' },
|
||||
{ id: 4, image: 'D' },
|
||||
]"
|
||||
>
|
||||
<template #item>
|
||||
<div>a</div>
|
||||
</template>
|
||||
</v-select>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<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>
|
||||
@@ -1,33 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const text = ref<string | null>(null)
|
||||
|
||||
async function onSearch() {
|
||||
console.log('search')
|
||||
}
|
||||
import AssigneeFilter from '@/components/AssigneeFilter.vue'
|
||||
import AssigneeFilterAvatar from '@/components/AssigneeFilterAvatar.vue'
|
||||
import TextFilter from '@/components/TextFilter.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="u-grid2 ga-2">
|
||||
<v-text-field
|
||||
v-model="text"
|
||||
clearable
|
||||
placeholder="Search name..."
|
||||
@keydown.enter.prevent="onSearch"
|
||||
@click:clear="onSearch"
|
||||
/>
|
||||
<v-select variant="outlined" clearable :items="['abc', 'def']"></v-select>
|
||||
|
||||
<!-- <v-btn>filter</v-btn>-->
|
||||
|
||||
<!-- <v-btn variant="plain">clear filters</v-btn>-->
|
||||
<div class="u-grid2 ga-1 align-center">
|
||||
<text-filter />
|
||||
<assignee-filter />
|
||||
<assignee-filter-avatar />
|
||||
<v-btn variant="text">clear filters</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.u-grid2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: 1fr 10rem auto auto;
|
||||
}
|
||||
</style>
|
||||
@@ -30,7 +30,8 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="projects.length" class="">
|
||||
<div v-if="projects.length" class="d-flex align-center ga-2">
|
||||
Project:
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn border density="comfortable" variant="flat" :text="currentProjectName" v-bind="props" />
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const text = ref<string | null>(null)
|
||||
|
||||
function onSearch() {
|
||||
console.log('onSearch', text.value)
|
||||
}
|
||||
|
||||
function onClear() {
|
||||
text.value = ''
|
||||
console.log('onClear', text.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-text-field
|
||||
v-model="text"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-text"
|
||||
placeholder="Search..."
|
||||
@keydown.enter.prevent="onSearch"
|
||||
@click:clear="onClear"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
Reference in new issue
Block a user