356 lines
7.9 KiB
Vue
356 lines
7.9 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
import UiIcon from '@/components/UiIcon.vue';
|
|
import UserAvatar from '@/components/UserAvatar.vue';
|
|
import { matchesSearch } from '@/lib/search';
|
|
|
|
type PickUser = { id: string; fullName: string };
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
modelValue: string[];
|
|
users: PickUser[];
|
|
disabled?: boolean;
|
|
/** حداقل تعداد انتخاب (حذف آخرین نفر غیرفعال میشود) */
|
|
min?: number;
|
|
addLabel?: string;
|
|
emptyLabel?: string;
|
|
}>(),
|
|
{ disabled: false, min: 0, addLabel: 'افزودن', emptyLabel: 'کسی انتخاب نشده' },
|
|
);
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [ids: string[]];
|
|
/** پس از بستن لیست یا حذف یک نفر، اگر انتخاب تغییر کرده باشد */
|
|
change: [ids: string[]];
|
|
}>();
|
|
|
|
const open = ref(false);
|
|
const query = ref('');
|
|
const root = ref<HTMLElement | null>(null);
|
|
const searchInput = ref<HTMLInputElement | null>(null);
|
|
let snapshot: string[] = [];
|
|
|
|
const byId = computed(() => new Map(props.users.map((u) => [u.id, u])));
|
|
const selected = computed(() =>
|
|
props.modelValue.map((id) => byId.value.get(id) ?? { id, fullName: '—' }),
|
|
);
|
|
const filtered = computed(() => {
|
|
if (!query.value.trim()) return props.users;
|
|
return props.users.filter((u) => matchesSearch(query.value, [u.fullName]));
|
|
});
|
|
const canRemove = computed(() => props.modelValue.length > props.min);
|
|
|
|
function sameSet(a: string[], b: string[]) {
|
|
return a.length === b.length && a.every((id) => b.includes(id));
|
|
}
|
|
|
|
function openList() {
|
|
if (props.disabled) return;
|
|
snapshot = [...props.modelValue];
|
|
query.value = '';
|
|
open.value = true;
|
|
void nextTick(() => searchInput.value?.focus());
|
|
}
|
|
|
|
function closeList() {
|
|
if (!open.value) return;
|
|
open.value = false;
|
|
if (!sameSet(snapshot, props.modelValue)) emit('change', [...props.modelValue]);
|
|
}
|
|
|
|
function toggle(id: string) {
|
|
const has = props.modelValue.includes(id);
|
|
if (has && !canRemove.value) return;
|
|
const next = has ? props.modelValue.filter((x) => x !== id) : [...props.modelValue, id];
|
|
emit('update:modelValue', next);
|
|
}
|
|
|
|
function remove(id: string) {
|
|
if (props.disabled || !canRemove.value) return;
|
|
const next = props.modelValue.filter((x) => x !== id);
|
|
emit('update:modelValue', next);
|
|
if (!open.value) emit('change', next);
|
|
}
|
|
|
|
function onDocClick(e: MouseEvent) {
|
|
if (open.value && root.value && !root.value.contains(e.target as Node)) closeList();
|
|
}
|
|
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') closeList();
|
|
}
|
|
|
|
onMounted(() => document.addEventListener('mousedown', onDocClick));
|
|
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick));
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="root" class="ums" :class="{ disabled }" @keydown="onKey">
|
|
<div class="chips">
|
|
<span v-for="u in selected" :key="u.id" class="chip">
|
|
<UserAvatar :name="u.fullName" :user-id="u.id" :size="22" />
|
|
<span class="chip-name">{{ u.fullName }}</span>
|
|
<button
|
|
v-if="canRemove && !disabled"
|
|
type="button"
|
|
class="chip-x"
|
|
:aria-label="`حذف ${u.fullName}`"
|
|
@click.stop="remove(u.id)"
|
|
>
|
|
<UiIcon name="x" :size="12" />
|
|
</button>
|
|
</span>
|
|
<span v-if="!selected.length" class="empty">{{ emptyLabel }}</span>
|
|
<button
|
|
type="button"
|
|
class="add"
|
|
:disabled="disabled"
|
|
:aria-expanded="open"
|
|
@click.stop="open ? closeList() : openList()"
|
|
>
|
|
<UiIcon name="user-plus" :size="14" />
|
|
{{ addLabel }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="open" class="pop" @click.stop>
|
|
<label class="search">
|
|
<UiIcon name="search" :size="14" />
|
|
<input ref="searchInput" v-model="query" type="search" placeholder="جستجوی نام…" />
|
|
</label>
|
|
<ul class="list">
|
|
<li v-for="u in filtered" :key="u.id">
|
|
<button
|
|
type="button"
|
|
class="row"
|
|
:class="{ on: modelValue.includes(u.id) }"
|
|
@click="toggle(u.id)"
|
|
>
|
|
<span class="check" aria-hidden="true">
|
|
<UiIcon v-if="modelValue.includes(u.id)" name="check-plain" :size="12" />
|
|
</span>
|
|
<UserAvatar :name="u.fullName" :user-id="u.id" :size="26" />
|
|
<span class="row-name">{{ u.fullName }}</span>
|
|
</button>
|
|
</li>
|
|
<li v-if="!filtered.length" class="none">کاربری پیدا نشد.</li>
|
|
</ul>
|
|
<div class="foot">
|
|
<span>{{ modelValue.length.toLocaleString('fa-IR') }} نفر انتخاب شده</span>
|
|
<button type="button" class="done" @click="closeList">تأیید</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.ums {
|
|
position: relative;
|
|
width: 100%;
|
|
min-width: 0;
|
|
}
|
|
|
|
.chips {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
}
|
|
|
|
.chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
padding: 0.18rem 0.25rem;
|
|
padding-inline-end: 0.5rem;
|
|
border-radius: 999px;
|
|
background: #f0fdfa;
|
|
border: 1px solid #ccfbf1;
|
|
color: #0f172a;
|
|
font-size: 0.82rem;
|
|
font-weight: 600;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.chip-name {
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.chip-x {
|
|
width: 20px;
|
|
height: 20px;
|
|
border: 0;
|
|
border-radius: 999px;
|
|
background: transparent;
|
|
color: #64748b;
|
|
display: grid;
|
|
place-items: center;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
}
|
|
|
|
.chip-x:hover {
|
|
background: #fee2e2;
|
|
color: #b91c1c;
|
|
}
|
|
|
|
.empty {
|
|
color: #94a3b8;
|
|
font-size: 0.82rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.add {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
height: 30px;
|
|
padding: 0 0.7rem;
|
|
border: 1px dashed #5eead4;
|
|
border-radius: 999px;
|
|
background: #fff;
|
|
color: #0f766e;
|
|
font: inherit;
|
|
font-size: 0.8rem;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.add:hover:not(:disabled) {
|
|
background: #f0fdfa;
|
|
}
|
|
|
|
.add:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.pop {
|
|
position: absolute;
|
|
top: calc(100% + 6px);
|
|
inset-inline-start: 0;
|
|
z-index: 30;
|
|
width: min(18rem, calc(100vw - 2rem));
|
|
background: #fff;
|
|
border-radius: var(--radius);
|
|
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16);
|
|
border: 1px solid #f1f5f9;
|
|
display: grid;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.search {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
margin: 0.5rem;
|
|
padding: 0 0.6rem;
|
|
height: 2.2rem;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 10px;
|
|
color: #94a3b8;
|
|
}
|
|
|
|
.search input {
|
|
flex: 1;
|
|
min-width: 0;
|
|
border: 0;
|
|
outline: none;
|
|
font: inherit;
|
|
font-size: 0.85rem;
|
|
background: transparent;
|
|
color: #0f172a;
|
|
}
|
|
|
|
.list {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0 0.35rem 0.35rem;
|
|
max-height: 15rem;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.row {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.55rem;
|
|
padding: 0.42rem 0.5rem;
|
|
border: 0;
|
|
border-radius: 10px;
|
|
background: none;
|
|
font: inherit;
|
|
font-size: 0.86rem;
|
|
font-weight: 500;
|
|
color: #0f172a;
|
|
text-align: start;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.row:hover {
|
|
background: #f8fafc;
|
|
}
|
|
|
|
.row.on {
|
|
background: #f0fdfa;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.check {
|
|
width: 18px;
|
|
height: 18px;
|
|
flex-shrink: 0;
|
|
border-radius: 5px;
|
|
border: 1.5px solid #cbd5e1;
|
|
display: grid;
|
|
place-items: center;
|
|
color: #fff;
|
|
}
|
|
|
|
.row.on .check {
|
|
background: #14b8a6;
|
|
border-color: #14b8a6;
|
|
}
|
|
|
|
.row-name {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.none {
|
|
padding: 0.75rem;
|
|
color: #94a3b8;
|
|
font-size: 0.84rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.foot {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 0.65rem;
|
|
border-top: 1px solid #f1f5f9;
|
|
font-size: 0.78rem;
|
|
color: #64748b;
|
|
}
|
|
|
|
.done {
|
|
border: 0;
|
|
border-radius: 8px;
|
|
background: #14b8a6;
|
|
color: #fff;
|
|
font: inherit;
|
|
font-size: 0.8rem;
|
|
font-weight: 700;
|
|
padding: 0.35rem 0.9rem;
|
|
cursor: pointer;
|
|
}
|
|
</style>
|