Add network map UX and theme support.
Introduce network map view/components with filtering and positioning, expand contacts/map backend fields and migrations, and add a persistent light/dark theme toggle with graph label color updates for readability. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ContactForm from './ContactForm.vue'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
default: {
|
||||
get: vi.fn(async () => ({
|
||||
data: {
|
||||
life_spheres: [{ value: 'work', label: 'Работа' }],
|
||||
network_circles: [{ value: 'support', label: 'Круг поддержки' }],
|
||||
},
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('ContactForm', () => {
|
||||
it('emits submit with normalized payload', async () => {
|
||||
const wrapper = mount(ContactForm, { props: { initial: { name: 'Иван' } } })
|
||||
await wrapper.get('form').trigger('submit.prevent')
|
||||
const payload = wrapper.emitted('submit')[0][0]
|
||||
expect(payload.name).toBe('Иван')
|
||||
expect(payload.importance).toBeDefined()
|
||||
expect(payload.include_on_network_map).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,35 @@
|
||||
<input v-model="form.position" class="form-control" placeholder="Директор" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-form-map-fields">
|
||||
<div class="form-group">
|
||||
<label>Сфера жизни</label>
|
||||
<SearchableSelect
|
||||
v-model="form.life_sphere"
|
||||
:options="lifeSpheres"
|
||||
placeholder="Сфера жизни..."
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Круг сети</label>
|
||||
<SearchableSelect
|
||||
v-model="form.network_circle"
|
||||
:options="networkCircles"
|
||||
placeholder="Круг сети..."
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Важность (1–5)</label>
|
||||
<input v-model.number="form.importance" type="number" min="1" max="5" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group checkbox-row">
|
||||
<label class="checkbox-label">
|
||||
<input v-model="form.include_on_network_map" type="checkbox" />
|
||||
Показывать на карте сети
|
||||
</label>
|
||||
<p class="checkbox-hint">На странице «Граф» контакт виден всегда; на «Карте сети» — только с этой отметкой.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Заметки</label>
|
||||
<textarea v-model="form.notes" class="form-control" placeholder="Дополнительная информация..." rows="3"></textarea>
|
||||
@@ -36,11 +65,30 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, watch } from 'vue'
|
||||
import { reactive, watch, ref, onMounted } from 'vue'
|
||||
import api from '../api'
|
||||
import SearchableSelect from './SearchableSelect.vue'
|
||||
|
||||
const FALLBACK_SPHERES = [
|
||||
{ value: 'work', label: 'Работа' },
|
||||
{ value: 'study', label: 'Учёба' },
|
||||
{ value: 'hobby', label: 'Хобби' },
|
||||
{ value: 'family', label: 'Семья' },
|
||||
{ value: 'health', label: 'Здоровье' },
|
||||
{ value: 'other', label: 'Другое' },
|
||||
]
|
||||
const FALLBACK_CIRCLES = [
|
||||
{ value: 'support', label: 'Круг поддержки' },
|
||||
{ value: 'productivity', label: 'Круг продуктивности' },
|
||||
{ value: 'development', label: 'Круг развития' },
|
||||
]
|
||||
|
||||
const props = defineProps({ initial: { type: Object, default: () => ({}) } })
|
||||
const emit = defineEmits(['submit', 'cancel'])
|
||||
|
||||
const lifeSpheres = ref([...FALLBACK_SPHERES])
|
||||
const networkCircles = ref([...FALLBACK_CIRCLES])
|
||||
|
||||
const form = reactive({
|
||||
name: props.initial.name || '',
|
||||
email: props.initial.email || '',
|
||||
@@ -48,13 +96,78 @@ const form = reactive({
|
||||
organization: props.initial.organization || '',
|
||||
position: props.initial.position || '',
|
||||
notes: props.initial.notes || '',
|
||||
life_sphere: props.initial.life_sphere || 'other',
|
||||
network_circle: props.initial.network_circle || 'productivity',
|
||||
importance: props.initial.importance ?? 3,
|
||||
include_on_network_map: Boolean(props.initial.include_on_network_map),
|
||||
})
|
||||
|
||||
watch(() => props.initial, (v) => {
|
||||
if (v) Object.assign(form, v)
|
||||
if (!v || !Object.keys(v).length) {
|
||||
Object.assign(form, {
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
organization: '',
|
||||
position: '',
|
||||
notes: '',
|
||||
life_sphere: 'other',
|
||||
network_circle: 'productivity',
|
||||
importance: 3,
|
||||
include_on_network_map: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
Object.assign(form, {
|
||||
name: v.name || '',
|
||||
email: v.email || '',
|
||||
phone: v.phone || '',
|
||||
organization: v.organization || '',
|
||||
position: v.position || '',
|
||||
notes: v.notes || '',
|
||||
life_sphere: v.life_sphere || 'other',
|
||||
network_circle: v.network_circle || 'productivity',
|
||||
importance: v.importance ?? 3,
|
||||
include_on_network_map: Boolean(v.include_on_network_map),
|
||||
})
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/network-map-choices/')
|
||||
if (data.life_spheres?.length) lifeSpheres.value = data.life_spheres
|
||||
if (data.network_circles?.length) networkCircles.value = data.network_circles
|
||||
} catch {
|
||||
/* оставляем FALLBACK_* */
|
||||
}
|
||||
})
|
||||
|
||||
function onSubmit() {
|
||||
emit('submit', { ...form })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contact-form-map-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.checkbox-hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="graph-view-header">
|
||||
<h2>{{ title }}</h2>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary btn-sm" @click="$emit('reset')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
</svg>
|
||||
{{ resetLabel }}
|
||||
</button>
|
||||
<button v-if="showPhysicsToggle" class="btn btn-secondary btn-sm" @click="$emit('toggle-physics')">
|
||||
{{ physicsEnabled ? physicsOnLabel : physicsOffLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, default: 'Граф связей' },
|
||||
resetLabel: { type: String, default: 'Сбросить вид' },
|
||||
showPhysicsToggle: { type: Boolean, default: false },
|
||||
physicsEnabled: { type: Boolean, default: true },
|
||||
physicsOnLabel: { type: String, default: 'Заморозить' },
|
||||
physicsOffLabel: { type: String, default: 'Оживить' },
|
||||
})
|
||||
defineEmits(['reset', 'toggle-physics'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.graph-view-header {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 28px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.graph-view-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="network-map-legend card">
|
||||
<div class="legend-row">
|
||||
<span><strong class="legend-strong">━━</strong> интенсивные</span>
|
||||
<span><strong class="legend-strong">╌╌</strong> редкие</span>
|
||||
<span class="legend-hint">{{ hint }}</span>
|
||||
</div>
|
||||
<div class="legend-rings">
|
||||
<span class="legend-ring-inner">внутреннее кольцо — поддержка</span>
|
||||
<span class="legend-ring-mid">среднее — продуктивность</span>
|
||||
<span class="legend-ring-outer">внешнее — развитие</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
hint: {
|
||||
type: String,
|
||||
default: 'Имя — в подсказке при наведении; клик — карточка. Масштаб и панорама — мышью; точки закреплены в секторах.',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.network-map-legend {
|
||||
margin: 0 28px 10px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.legend-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
.legend-strong {
|
||||
color: var(--text);
|
||||
}
|
||||
.legend-hint {
|
||||
flex: 1 1 200px;
|
||||
font-size: 11px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.legend-rings {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 18px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.legend-ring-inner::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: -1px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.legend-ring-mid::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: -2px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.legend-ring-outer::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: -3px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="network-map-top-panel" :class="{ collapsed }">
|
||||
<button
|
||||
class="panel-toggle-btn"
|
||||
@click="$emit('toggle-collapse')"
|
||||
:title="collapsed ? 'Развернуть панель' : 'Свернуть панель'"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline v-if="!collapsed" points="18 15 12 9 6 15" />
|
||||
<polyline v-else points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="network-map-header" v-show="!collapsed">
|
||||
<div>
|
||||
<h2>{{ title }}</h2>
|
||||
<p class="network-map-sub">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary btn-sm" @click="$emit('fit')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
|
||||
</svg>
|
||||
По центру
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="network-map-toolbar" v-show="!collapsed">
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
|
||||
<div v-show="!collapsed">
|
||||
<slot name="legend" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
collapsed: { type: Boolean, default: false },
|
||||
title: { type: String, default: 'Карта сети' },
|
||||
subtitle: {
|
||||
type: String,
|
||||
default:
|
||||
'Три круга — поддержка, продуктивность, развитие. Секторы — сферы жизни. Толстая линия — частые контакты, пунктир — редкие. Стрелка — от инициатора связи.',
|
||||
},
|
||||
})
|
||||
defineEmits(['toggle-collapse', 'fit'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.network-map-top-panel {
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.network-map-top-panel.collapsed {
|
||||
height: 26px;
|
||||
}
|
||||
.panel-toggle-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 8px;
|
||||
z-index: 3;
|
||||
width: 24px;
|
||||
height: 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.panel-toggle-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.network-map-header {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 28px 8px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.network-map-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.network-map-sub {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
max-width: 640px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.network-map-toolbar {
|
||||
flex-shrink: 0;
|
||||
padding: 0 28px 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="relation-filters">
|
||||
<button
|
||||
v-for="rt in relationTypes"
|
||||
:key="rt.value"
|
||||
class="btn btn-sm"
|
||||
:class="activeValues.includes(rt.value) ? 'btn-primary' : 'btn-secondary'"
|
||||
@click="$emit('toggle', rt.value)"
|
||||
>
|
||||
{{ rt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
relationTypes: { type: Array, default: () => [] },
|
||||
activeValues: { type: Array, default: () => [] },
|
||||
})
|
||||
defineEmits(['toggle'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.relation-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import SearchableSelect from './SearchableSelect.vue'
|
||||
|
||||
const options = [
|
||||
{ value: 1, label: 'Иван Петров' },
|
||||
{ value: 2, label: 'Мария Сидорова' },
|
||||
]
|
||||
|
||||
describe('SearchableSelect', () => {
|
||||
it('filters options while typing', async () => {
|
||||
const wrapper = mount(SearchableSelect, {
|
||||
props: { modelValue: '', options, placeholder: 'Поиск' },
|
||||
})
|
||||
const input = wrapper.get('input')
|
||||
await input.setValue('мария')
|
||||
await input.trigger('focus')
|
||||
|
||||
const items = wrapper.findAll('.searchable-select__option')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].text()).toBe('Мария Сидорова')
|
||||
})
|
||||
|
||||
it('emits selected value on option click', async () => {
|
||||
const wrapper = mount(SearchableSelect, {
|
||||
props: { modelValue: '', options },
|
||||
})
|
||||
await wrapper.get('input').setValue('иван')
|
||||
await wrapper.get('input').trigger('focus')
|
||||
await wrapper.find('.searchable-select__option').trigger('mousedown')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[1]])
|
||||
})
|
||||
|
||||
it('shows label for current model value', async () => {
|
||||
const wrapper = mount(SearchableSelect, {
|
||||
props: { modelValue: 2, options },
|
||||
})
|
||||
expect(wrapper.get('input').element.value).toBe('Мария Сидорова')
|
||||
})
|
||||
|
||||
it('keeps input empty when no value is selected', async () => {
|
||||
const wrapper = mount(SearchableSelect, {
|
||||
props: { modelValue: '', options, placeholder: 'Введите имя' },
|
||||
})
|
||||
expect(wrapper.get('input').element.value).toBe('')
|
||||
expect(wrapper.get('input').attributes('placeholder')).toBe('Введите имя')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<div class="searchable-select" ref="rootRef">
|
||||
<input
|
||||
ref="inputRef"
|
||||
type="text"
|
||||
class="form-control searchable-select__input"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:value="query"
|
||||
autocomplete="off"
|
||||
role="combobox"
|
||||
:aria-expanded="open"
|
||||
aria-autocomplete="list"
|
||||
@input="onInput"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<ul v-if="open && filteredOptions.length" class="searchable-select__list" role="listbox">
|
||||
<li
|
||||
v-for="(opt, index) in filteredOptions"
|
||||
:key="String(opt.value)"
|
||||
role="option"
|
||||
:aria-selected="isSelected(opt)"
|
||||
class="searchable-select__option"
|
||||
:class="{
|
||||
'is-active': index === highlightedIndex,
|
||||
'is-selected': isSelected(opt),
|
||||
}"
|
||||
@mousedown.prevent="selectOption(opt)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="open && query.trim() && !filteredOptions.length" class="searchable-select__empty">
|
||||
Ничего не найдено
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
options: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: 'Начните вводить...' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const rootRef = ref(null)
|
||||
const inputRef = ref(null)
|
||||
const query = ref('')
|
||||
const open = ref(false)
|
||||
const highlightedIndex = ref(0)
|
||||
|
||||
const allOptions = computed(() =>
|
||||
props.options.map((o) => ({ value: o.value, label: o.label }))
|
||||
)
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const q = query.value.trim().toLocaleLowerCase('ru')
|
||||
if (!q) return allOptions.value
|
||||
return allOptions.value.filter((o) =>
|
||||
o.label.toLocaleLowerCase('ru').includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
function valuesEqual(a, b) {
|
||||
if (a === '' || a === null || a === undefined) {
|
||||
return b === '' || b === null || b === undefined
|
||||
}
|
||||
return String(a) === String(b)
|
||||
}
|
||||
|
||||
function isEmptyValue(value) {
|
||||
return value === '' || value === null || value === undefined
|
||||
}
|
||||
|
||||
function labelForValue(value) {
|
||||
if (isEmptyValue(value)) return ''
|
||||
const opt = allOptions.value.find((o) => valuesEqual(o.value, value))
|
||||
return opt?.label ?? ''
|
||||
}
|
||||
|
||||
function isSelected(opt) {
|
||||
return valuesEqual(opt.value, props.modelValue)
|
||||
}
|
||||
|
||||
function syncQueryFromModel() {
|
||||
query.value = labelForValue(props.modelValue)
|
||||
}
|
||||
|
||||
function onInput(e) {
|
||||
query.value = e.target.value
|
||||
open.value = true
|
||||
highlightedIndex.value = 0
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
open.value = true
|
||||
if (isEmptyValue(props.modelValue)) {
|
||||
query.value = ''
|
||||
highlightedIndex.value = 0
|
||||
return
|
||||
}
|
||||
query.value = labelForValue(props.modelValue)
|
||||
highlightedIndex.value = Math.max(
|
||||
0,
|
||||
filteredOptions.value.findIndex((o) => isSelected(o))
|
||||
)
|
||||
nextTick(() => inputRef.value?.select())
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
setTimeout(() => {
|
||||
open.value = false
|
||||
syncQueryFromModel()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
function selectOption(opt) {
|
||||
emit('update:modelValue', opt.value)
|
||||
query.value = isEmptyValue(opt.value) ? '' : opt.label
|
||||
open.value = false
|
||||
highlightedIndex.value = 0
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (!open.value && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
||||
open.value = true
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
open.value = false
|
||||
syncQueryFromModel()
|
||||
inputRef.value?.blur()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
if (!filteredOptions.value.length) return
|
||||
highlightedIndex.value = Math.min(
|
||||
highlightedIndex.value + 1,
|
||||
filteredOptions.value.length - 1
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (!filteredOptions.value.length) return
|
||||
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const opt = filteredOptions.value[highlightedIndex.value]
|
||||
if (opt) selectOption(opt)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
if (!open.value) syncQueryFromModel()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.options,
|
||||
() => {
|
||||
if (!open.value) syncQueryFromModel()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(filteredOptions, (list) => {
|
||||
if (highlightedIndex.value >= list.length) {
|
||||
highlightedIndex.value = Math.max(0, list.length - 1)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.searchable-select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.searchable-select__input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.searchable-select__list {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
list-style: none;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.searchable-select__option {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.searchable-select__option:hover,
|
||||
.searchable-select__option.is-active {
|
||||
background: var(--surface-alt);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.searchable-select__option.is-selected {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.searchable-select__empty {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user