Files
fabric_trade_admin/src/components/ui/AdminSelect.vue
T

299 lines
6.8 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { onClickOutside } from '@vueuse/core'
import AdminIcon from '@/components/icons/AdminIcon.vue'
export type AdminSelectOption = {
value: string
label: string
}
const props = withDefaults(
defineProps<{
modelValue: string
options: AdminSelectOption[]
placeholder?: string
ariaLabel?: string
block?: boolean
searchable?: boolean
searchPlaceholder?: string
id?: string
}>(),
{
placeholder: 'انتخاب کنید',
ariaLabel: 'انتخاب',
block: false,
searchable: false,
searchPlaceholder: 'جستجو…',
id: undefined,
},
)
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const open = ref(false)
const searchQuery = ref('')
const rootRef = ref<HTMLElement | null>(null)
const searchInputRef = ref<HTMLInputElement | null>(null)
onClickOutside(rootRef, () => {
open.value = false
searchQuery.value = ''
})
const selectedLabel = computed(
() => props.options.find((option) => option.value === props.modelValue)?.label ?? props.placeholder,
)
const filteredOptions = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return props.options
return props.options.filter((option) => option.label.toLowerCase().includes(q))
})
async function toggle() {
open.value = !open.value
if (open.value && props.searchable) {
searchQuery.value = ''
await nextTick()
searchInputRef.value?.focus()
}
}
function pick(value: string) {
emit('update:modelValue', value)
open.value = false
searchQuery.value = ''
}
</script>
<template>
<div
:id="id"
ref="rootRef"
class="admin-select"
:class="{ 'admin-select--open': open, 'admin-select--block': block }"
>
<button
type="button"
class="admin-select__trigger"
:aria-label="ariaLabel"
:aria-expanded="open"
aria-haspopup="listbox"
@click="toggle"
>
<span class="admin-select__value">{{ selectedLabel }}</span>
<span class="admin-select__chevron" aria-hidden="true">
<AdminIcon name="chevron-down" :size="16" />
</span>
</button>
<ul
v-if="open"
class="admin-select__menu"
role="listbox"
:aria-label="ariaLabel"
>
<li v-if="searchable" class="admin-select__search" role="presentation">
<input
ref="searchInputRef"
v-model="searchQuery"
type="search"
class="admin-select__search-input"
:placeholder="searchPlaceholder"
@click.stop
@keydown.esc.stop="open = false"
/>
</li>
<li v-if="!filteredOptions.length" class="admin-select__empty" role="presentation">
موردی یافت نشد
</li>
<li v-for="option in filteredOptions" :key="option.value" role="presentation">
<button
type="button"
class="admin-select__option"
:class="{ 'admin-select__option--active': option.value === modelValue }"
role="option"
:aria-selected="option.value === modelValue"
@click="pick(option.value)"
>
<span>{{ option.label }}</span>
<AdminIcon
v-if="option.value === modelValue"
name="check"
:size="16"
class="admin-select__check"
/>
</button>
</li>
</ul>
</div>
</template>
<style scoped>
.admin-select {
position: relative;
min-width: 200px;
max-width: 100%;
}
.admin-select--block {
width: 100%;
min-width: 0;
}
.admin-select__trigger {
width: 100%;
min-height: var(--control-height, 40px);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 12px 0 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
background var(--transition-fast);
}
.admin-select__trigger:hover {
border-color: var(--color-primary);
background: var(--color-elevated, var(--color-surface));
}
.admin-select--open .admin-select__trigger,
.admin-select__trigger:focus-visible {
outline: none;
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.admin-select__value {
flex: 1;
min-width: 0;
text-align: start;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-select__chevron {
display: inline-flex;
flex-shrink: 0;
color: var(--color-text-muted);
transition: transform var(--transition-fast);
}
.admin-select--open .admin-select__chevron {
transform: rotate(180deg);
color: var(--color-primary);
}
.admin-select__menu {
position: absolute;
inset-inline-start: 0;
top: calc(100% + 6px);
z-index: 40;
width: max(100%, 220px);
max-height: min(320px, 50vh);
margin: 0;
padding: 8px;
list-style: none;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card, 0 12px 28px rgba(15, 23, 42, 0.12));
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
}
.admin-select__search {
position: sticky;
top: 0;
z-index: 1;
background: var(--color-surface);
padding-bottom: 4px;
}
.admin-select__search-input {
width: 100%;
height: 36px;
padding: 0 12px;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-bg);
color: var(--color-text);
font: inherit;
font-size: 0.8125rem;
}
.admin-select__search-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.admin-select__empty {
padding: 12px;
font-size: 0.8125rem;
color: var(--color-text-muted);
text-align: center;
}
.admin-select__option {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
color: var(--color-text);
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
text-align: start;
cursor: pointer;
transition:
background var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
}
.admin-select__option:hover {
background: var(--color-bg);
border-color: var(--color-border);
}
.admin-select__option--active {
background: var(--color-primary-soft);
border-color: transparent;
color: var(--color-primary);
font-weight: 600;
}
.admin-select__option--active:hover {
background: var(--color-primary-soft);
border-color: transparent;
}
.admin-select__check {
flex-shrink: 0;
color: var(--color-primary);
}
</style>