adata-ui 2.1.41-beta.5 → 2.1.41-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -1
- package/assets/styles/theme.css +11 -4
- package/components/elements/select/ACascadeSelectV2.vue +710 -0
- package/components/elements/select/ASelectV2.vue +70 -143
- package/components/elements/select/cascade-utils.ts +115 -0
- package/components/elements/select/types.ts +27 -0
- package/components/elements/select/ui/cascade-drawer.vue +146 -0
- package/components/elements/select/ui/cascade-panel.vue +237 -0
- package/components/elements/select/ui/select-trigger.vue +167 -0
- package/components/overlays/drawer/ADrawerV2.vue +9 -2
- package/components/overlays/modal/AModalV2.vue +1 -0
- package/icons/arrow/arrow-down-left.vue +6 -0
- package/icons/arrow/arrow-up-right.vue +6 -0
- package/icons/crown.vue +6 -0
- package/icons/refresh-cw.vue +8 -0
- package/icons/user-plus.vue +3 -0
- package/illustrations/scheme-notes.vue +21 -0
- package/illustrations/scheme-waypoints.vue +25 -0
- package/package.json +1 -1
- package/utils/toCssSize.ts +11 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
<script generic="T" lang="ts" setup>
|
|
2
|
+
import type { CascadeEntry, CascadeKeys, CascadeMode } from './types'
|
|
3
|
+
import { toCssSize } from '#adata-ui/utils/toCssSize'
|
|
4
|
+
import { autoUpdate, flip, size as floatingSize, offset, shift, useFloating } from '@floating-ui/vue'
|
|
5
|
+
import { onClickOutside, useMediaQuery, useToggle, useWindowSize } from '@vueuse/core'
|
|
6
|
+
import { isEqual } from 'lodash-es'
|
|
7
|
+
import { buildCascadeIndex, isDisabled, isLeaf, nodeKey, nodeLabel, pathLabel, resolvePanels } from './cascade-utils'
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
label: string
|
|
11
|
+
options: T[]
|
|
12
|
+
required?: boolean
|
|
13
|
+
keyToShow?: string
|
|
14
|
+
keyToSelect?: string
|
|
15
|
+
childrenKey?: string
|
|
16
|
+
disabledKey?: string
|
|
17
|
+
/** `true` — в v-model лежит ключ листа, `false` — сам объект листа. */
|
|
18
|
+
getOnlyKey?: boolean
|
|
19
|
+
clearable?: boolean
|
|
20
|
+
disabled?: boolean
|
|
21
|
+
size?: 'sm' | 'md'
|
|
22
|
+
error?: string | string[]
|
|
23
|
+
startIcon?: object
|
|
24
|
+
buttonBg?: string
|
|
25
|
+
/** Куда растут подпанели. `auto` — вправо, а если не влезает, влево. */
|
|
26
|
+
panelsSide?: 'auto' | 'right' | 'left'
|
|
27
|
+
/** Принудительный режим раскрытия вместо авто-выбора по ширине экрана. */
|
|
28
|
+
submenuTrigger?: 'auto' | 'hover' | 'click'
|
|
29
|
+
hoverOpenDelay?: number
|
|
30
|
+
hoverCloseDelay?: number
|
|
31
|
+
/** Ширина колонки. По умолчанию — ширина триггера. */
|
|
32
|
+
panelWidth?: number | string
|
|
33
|
+
/** Нижняя граница ширины колонки — когда триггер узкий, а подписи длинные. */
|
|
34
|
+
panelMinWidth?: number | string
|
|
35
|
+
panelMaxHeight?: number | string
|
|
36
|
+
/** Подпись первой колонки. По умолчанию — `label`. */
|
|
37
|
+
panelTitle?: string
|
|
38
|
+
/** Показывать в триггере весь путь («Кварталы / 1 квартал 2026»). */
|
|
39
|
+
showPathInTrigger?: boolean
|
|
40
|
+
drawerTitle?: string
|
|
41
|
+
/** Фиксированная общая высота мобильного дровера («70dvh»). */
|
|
42
|
+
drawerHeight?: number | string
|
|
43
|
+
drawerMaxHeight?: number | string
|
|
44
|
+
backLabel?: string
|
|
45
|
+
emptyLabel?: string
|
|
46
|
+
mobileBreakpoint?: number
|
|
47
|
+
tabletBreakpoint?: number
|
|
48
|
+
teleport?: boolean
|
|
49
|
+
/** Слой выпадашки и мобильного дровера. */
|
|
50
|
+
zIndex?: number | string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Emits {
|
|
54
|
+
(e: 'onChange', option: T, path: T[]): void
|
|
55
|
+
(e: 'onClear'): void
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Каскадный селект: опция-родитель раскрывает своих детей отдельной колонкой сбоку.
|
|
60
|
+
*
|
|
61
|
+
* Три режима, выбираются по ширине экрана (или принудительно через `submenuTrigger`):
|
|
62
|
+
* • `columns-hover` (десктоп) — подпанель по наведению;
|
|
63
|
+
* • `columns-click` (планшет) — подпанель по клику, у дочерней колонки есть «Назад»;
|
|
64
|
+
* • `drawer` (мобильный) — `a-drawer-v2` с drill-down по одной колонке.
|
|
65
|
+
*
|
|
66
|
+
* Выбирается ТОЛЬКО лист: узел с детьми лишь раскрывает ветку и значение не меняет.
|
|
67
|
+
*/
|
|
68
|
+
defineOptions({ name: 'ACascadeSelectV2' })
|
|
69
|
+
|
|
70
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
71
|
+
required: false,
|
|
72
|
+
keyToShow: 'name',
|
|
73
|
+
keyToSelect: 'id',
|
|
74
|
+
childrenKey: 'children',
|
|
75
|
+
disabledKey: undefined,
|
|
76
|
+
getOnlyKey: false,
|
|
77
|
+
clearable: true,
|
|
78
|
+
disabled: false,
|
|
79
|
+
size: 'sm',
|
|
80
|
+
error: undefined,
|
|
81
|
+
startIcon: undefined,
|
|
82
|
+
buttonBg: '',
|
|
83
|
+
panelsSide: 'auto',
|
|
84
|
+
submenuTrigger: 'auto',
|
|
85
|
+
hoverOpenDelay: 120,
|
|
86
|
+
hoverCloseDelay: 200,
|
|
87
|
+
panelWidth: undefined,
|
|
88
|
+
panelMinWidth: undefined,
|
|
89
|
+
panelMaxHeight: undefined,
|
|
90
|
+
panelTitle: undefined,
|
|
91
|
+
showPathInTrigger: false,
|
|
92
|
+
drawerTitle: undefined,
|
|
93
|
+
drawerHeight: undefined,
|
|
94
|
+
drawerMaxHeight: undefined,
|
|
95
|
+
backLabel: 'Назад',
|
|
96
|
+
emptyLabel: 'Ничего не найдено',
|
|
97
|
+
mobileBreakpoint: 768,
|
|
98
|
+
tabletBreakpoint: 1024,
|
|
99
|
+
teleport: true,
|
|
100
|
+
zIndex: 10000,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const emits = defineEmits<Emits>()
|
|
104
|
+
|
|
105
|
+
defineSlots<{
|
|
106
|
+
'option'?: (props: { option: T, level: number, selected: boolean, hasChildren: boolean, active: boolean }) => unknown
|
|
107
|
+
'single-selected'?: (props: { value: T, path: T[] }) => unknown
|
|
108
|
+
'empty'?: () => unknown
|
|
109
|
+
}>()
|
|
110
|
+
|
|
111
|
+
const modelValue = defineModel<T | unknown | null>({ required: true })
|
|
112
|
+
const isOpen = defineModel<boolean>('open', { default: false })
|
|
113
|
+
|
|
114
|
+
const keys = computed<CascadeKeys>(() => ({
|
|
115
|
+
keyToShow: props.keyToShow,
|
|
116
|
+
keyToSelect: props.keyToSelect,
|
|
117
|
+
childrenKey: props.childrenKey,
|
|
118
|
+
disabledKey: props.disabledKey,
|
|
119
|
+
}))
|
|
120
|
+
|
|
121
|
+
/* Один обход дерева на смену options: дальше все выборки по ключу — O(1). */
|
|
122
|
+
const index = computed(() => buildCascadeIndex(props.options, keys.value))
|
|
123
|
+
|
|
124
|
+
const selectedKey = computed<unknown>(() => {
|
|
125
|
+
const value = modelValue.value
|
|
126
|
+
if (value == null || value === '') return undefined
|
|
127
|
+
return props.getOnlyKey ? value : nodeKey(value as T, keys.value)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const selectedEntry = computed<CascadeEntry<T> | null>(() =>
|
|
131
|
+
selectedKey.value === undefined ? null : index.value.get(selectedKey.value) ?? null,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
/** Ключи предков выбранного листа — ими раскрываем ветку при открытии. */
|
|
135
|
+
const selectedAncestorKeys = computed<unknown[]>(() =>
|
|
136
|
+
selectedEntry.value ? selectedEntry.value.keyPath.slice(0, -1) : [],
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
// -- Навигация -------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
/** Цепочка ключей раскрытых родителей: activePath[i] раскрыт из колонки i. */
|
|
142
|
+
const activePath = ref<unknown[]>([])
|
|
143
|
+
|
|
144
|
+
const resolved = computed(() => resolvePanels(props.options, activePath.value, index.value, keys.value))
|
|
145
|
+
const panels = computed<T[][]>(() => resolved.value.panels)
|
|
146
|
+
/** Путь, обрезанный до реально разрешимого: options могли смениться под нами. */
|
|
147
|
+
const resolvedPath = computed<unknown[]>(() => resolved.value.keyPath)
|
|
148
|
+
|
|
149
|
+
// -- Режим -----------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
const isMobileViewport = useMediaQuery(computed(() => `(max-width: ${props.mobileBreakpoint - 1}px)`))
|
|
152
|
+
const isTabletViewport = useMediaQuery(
|
|
153
|
+
computed(() => `(min-width: ${props.mobileBreakpoint}px) and (max-width: ${props.tabletBreakpoint - 1}px)`),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const mode = computed<CascadeMode>(() => {
|
|
157
|
+
if (isMobileViewport.value) return 'drawer'
|
|
158
|
+
if (props.submenuTrigger === 'hover') return 'columns-hover'
|
|
159
|
+
if (props.submenuTrigger === 'click') return 'columns-click'
|
|
160
|
+
return isTabletViewport.value ? 'columns-click' : 'columns-hover'
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const isDrawerMode = computed(() => mode.value === 'drawer')
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Планшет и дровер показывают одну колонку — текущую: вернуться к родителю есть чем
|
|
167
|
+
* («Назад»), а вторая колонка только съедала бы ширину. Десктоп показывает все.
|
|
168
|
+
* Абсолютный `level` сохраняем, чтобы срез не ломал индексацию путей и рефов.
|
|
169
|
+
*/
|
|
170
|
+
const panelViews = computed<{ options: T[], level: number }[]>(() => {
|
|
171
|
+
const all = panels.value.map((options, level) => ({ options, level }))
|
|
172
|
+
if (isDrawerMode.value || mode.value === 'columns-click') return all.slice(-1)
|
|
173
|
+
return all
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
/* undefined доезжает до a-cascade-drawer и там подхватывает его собственные дефолты. */
|
|
177
|
+
const drawerHeightStyle = computed(() => toCssSize(props.drawerHeight))
|
|
178
|
+
const drawerMaxHeightStyle = computed(() => toCssSize(props.drawerMaxHeight))
|
|
179
|
+
|
|
180
|
+
const drawerView = computed(() => panelViews.value[0] ?? { options: props.options, level: 0 })
|
|
181
|
+
const drawerParentLabel = computed(() => {
|
|
182
|
+
const level = drawerView.value.level
|
|
183
|
+
if (level === 0) return ''
|
|
184
|
+
const entry = index.value.get(resolvedPath.value[level - 1])
|
|
185
|
+
return entry ? nodeLabel(entry.node, keys.value) : ''
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// -- Позиционирование ------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
const wrapper = ref<HTMLElement | null>(null)
|
|
191
|
+
const triggerRef = ref<{ el: HTMLButtonElement | null, valueEl: HTMLElement | null } | null>(null)
|
|
192
|
+
const reference = computed<HTMLElement | null>(() => triggerRef.value?.el ?? null)
|
|
193
|
+
const floating = ref<HTMLElement | null>(null)
|
|
194
|
+
|
|
195
|
+
const panelsDir = ref<'right' | 'left'>('right')
|
|
196
|
+
|
|
197
|
+
const autoMaxHeight = ref('320px')
|
|
198
|
+
/* Ширина триггера, снятая floating-ui: по ней колонки выравниваются с полем, как в ASelectV2. */
|
|
199
|
+
const autoPanelWidth = ref<string>()
|
|
200
|
+
|
|
201
|
+
const panelMaxHeightStyle = computed(() => toCssSize(props.panelMaxHeight) ?? autoMaxHeight.value)
|
|
202
|
+
const panelWidthStyle = computed(() => toCssSize(props.panelWidth) ?? autoPanelWidth.value)
|
|
203
|
+
const panelMinWidthStyle = computed(() => toCssSize(props.panelMinWidth))
|
|
204
|
+
|
|
205
|
+
function pxOf(value?: string): number {
|
|
206
|
+
const match = /^([\d.]+)px$/.exec(value ?? '')
|
|
207
|
+
return match ? Number.parseFloat(match[1]) : 0
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Фактическая ширина колонки в пикселях; 0 — размер задан нечисловой единицей, мерить нечем. */
|
|
211
|
+
const panelWidthPx = computed(() => {
|
|
212
|
+
const width = pxOf(panelWidthStyle.value)
|
|
213
|
+
if (!width) return 0
|
|
214
|
+
/* min-width может оказаться больше ширины триггера — тогда колонка шире, чем кажется. */
|
|
215
|
+
return Math.max(width, pxOf(panelMinWidthStyle.value))
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
const { width: viewportWidth } = useWindowSize()
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Колонки фиксированной ширины перестают влезать в экран уже на третьем уровне,
|
|
222
|
+
* а `shift` умеет только сдвигать. Поэтому режем лишние слева — тем же приёмом,
|
|
223
|
+
* что планшет (`slice(-2)`) и дровер (`slice(-1)`).
|
|
224
|
+
*/
|
|
225
|
+
const visiblePanelViews = computed(() => {
|
|
226
|
+
const all = panelViews.value
|
|
227
|
+
const panelPx = panelWidthPx.value
|
|
228
|
+
if (isDrawerMode.value || !panelPx || !viewportWidth.value) return all
|
|
229
|
+
|
|
230
|
+
const gap = 8 /* gap-2 между колонками */
|
|
231
|
+
const edges = 16 /* по 8px от краёв — столько же просит shift({ padding: 8 }) */
|
|
232
|
+
const fits = Math.max(1, Math.floor((viewportWidth.value - edges + gap) / (panelPx + gap)))
|
|
233
|
+
return all.length > fits ? all.slice(-fits) : all
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* «Назад» нужен на самой мелкой видимой колонке: если срез (планшетный или по ширине экрана)
|
|
238
|
+
* спрятал родителя, вернуться к нему больше нечем. На остальных колонках он был бы дублем.
|
|
239
|
+
*/
|
|
240
|
+
function showBackFor(level: number) {
|
|
241
|
+
if (level === 0) return false
|
|
242
|
+
return level === (visiblePanelViews.value[0]?.level ?? 0)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const { x, y, strategy, update } = useFloating(reference, floating, {
|
|
246
|
+
/* Колонки растут вправо от левого края триггера; в зеркальном режиме — влево от правого. */
|
|
247
|
+
placement: computed(() => (panelsDir.value === 'left' ? 'bottom-end' : 'bottom-start')),
|
|
248
|
+
strategy: 'fixed',
|
|
249
|
+
middleware: computed(() => [
|
|
250
|
+
offset(6),
|
|
251
|
+
flip({ fallbackPlacements: panelsDir.value === 'left' ? ['top-end'] : ['top-start'] }),
|
|
252
|
+
shift({ padding: 8 }),
|
|
253
|
+
floatingSize({
|
|
254
|
+
/* Пишем в ref только на смену значения: apply зовётся на каждый кадр autoUpdate. */
|
|
255
|
+
apply({ availableHeight, rects }) {
|
|
256
|
+
const nextHeight = `${Math.max(160, Math.min(availableHeight, 360))}px`
|
|
257
|
+
if (nextHeight !== autoMaxHeight.value) autoMaxHeight.value = nextHeight
|
|
258
|
+
|
|
259
|
+
const nextWidth = `${Math.round(rects.reference.width)}px`
|
|
260
|
+
if (nextWidth !== autoPanelWidth.value) autoPanelWidth.value = nextWidth
|
|
261
|
+
},
|
|
262
|
+
}),
|
|
263
|
+
]),
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
/* Только на смену: запись меняет placement, тот дёргает update — иначе петля. */
|
|
267
|
+
function setDirection(next: 'right' | 'left') {
|
|
268
|
+
if (next !== panelsDir.value) panelsDir.value = next
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Сторону выбираем один раз на изменение набора колонок, а не внутри middleware:
|
|
273
|
+
* измерение в `apply` меняло бы placement, тот — измерение, и получалась бы петля.
|
|
274
|
+
*/
|
|
275
|
+
function measureDirection() {
|
|
276
|
+
if (props.panelsSide !== 'auto') {
|
|
277
|
+
setDirection(props.panelsSide)
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
const rect = reference.value?.getBoundingClientRect()
|
|
281
|
+
const el = floating.value
|
|
282
|
+
if (!rect || !el) return
|
|
283
|
+
const needed = el.scrollWidth
|
|
284
|
+
const spaceRight = window.innerWidth - rect.left - 8
|
|
285
|
+
const spaceLeft = rect.right - 8
|
|
286
|
+
setDirection(needed > spaceRight && spaceLeft > spaceRight ? 'left' : 'right')
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
watch(() => props.panelsSide, () => measureDirection(), { immediate: true })
|
|
290
|
+
watch([visiblePanelViews, isOpen], () => {
|
|
291
|
+
if (!isOpen.value || isDrawerMode.value) return
|
|
292
|
+
nextTick(measureDirection)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
let cleanup: (() => void) | null = null
|
|
296
|
+
watch([isOpen, isDrawerMode], () => {
|
|
297
|
+
cleanup?.()
|
|
298
|
+
cleanup = null
|
|
299
|
+
if (!isOpen.value || isDrawerMode.value) return
|
|
300
|
+
nextTick(() => {
|
|
301
|
+
if (reference.value && floating.value) {
|
|
302
|
+
cleanup = autoUpdate(reference.value, floating.value, () => {
|
|
303
|
+
update()
|
|
304
|
+
measureDirection()
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
// -- Таймеры hover-intent (по образцу useTooltipTrigger) --------------------
|
|
311
|
+
|
|
312
|
+
let openTimer: ReturnType<typeof setTimeout> | null = null
|
|
313
|
+
let collapseTimer: ReturnType<typeof setTimeout> | null = null
|
|
314
|
+
|
|
315
|
+
function clearOpenTimer() {
|
|
316
|
+
if (openTimer) {
|
|
317
|
+
clearTimeout(openTimer)
|
|
318
|
+
openTimer = null
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function clearCollapseTimer() {
|
|
323
|
+
if (collapseTimer) {
|
|
324
|
+
clearTimeout(collapseTimer)
|
|
325
|
+
collapseTimer = null
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
onUnmounted(() => {
|
|
330
|
+
clearOpenTimer()
|
|
331
|
+
clearCollapseTimer()
|
|
332
|
+
cleanup?.()
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
// -- Действия --------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
const toggleOpen = useToggle(isOpen)
|
|
338
|
+
|
|
339
|
+
function openBranch(level: number, node: T) {
|
|
340
|
+
activePath.value = [...resolvedPath.value.slice(0, level), nodeKey(node, keys.value)]
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function collapseTo(level: number) {
|
|
344
|
+
activePath.value = resolvedPath.value.slice(0, level)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function selectLeaf(node: T) {
|
|
348
|
+
const entry = index.value.get(nodeKey(node, keys.value))
|
|
349
|
+
modelValue.value = props.getOnlyKey ? nodeKey(node, keys.value) : node
|
|
350
|
+
emits('onChange', node, entry?.path ?? [node])
|
|
351
|
+
toggleOpen(false)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function activate(level: number, node: T) {
|
|
355
|
+
if (isDisabled(node, keys.value)) return
|
|
356
|
+
clearOpenTimer()
|
|
357
|
+
clearCollapseTimer()
|
|
358
|
+
if (isLeaf(node, keys.value)) {
|
|
359
|
+
selectLeaf(node)
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
openBranch(level, node)
|
|
363
|
+
focusRow(level + 1, firstEnabledKey(level + 1))
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function goBackFrom(level: number) {
|
|
367
|
+
activePath.value = resolvedPath.value.slice(0, Math.max(0, level - 1))
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function goBack() {
|
|
371
|
+
goBackFrom(drawerView.value.level)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function onClear() {
|
|
375
|
+
modelValue.value = null
|
|
376
|
+
activePath.value = []
|
|
377
|
+
emits('onClear')
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
defineExpose({ onClear })
|
|
381
|
+
|
|
382
|
+
// -- Hover -----------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
function onRowHover(level: number, node: T) {
|
|
385
|
+
if (mode.value !== 'columns-hover') return
|
|
386
|
+
clearCollapseTimer()
|
|
387
|
+
clearOpenTimer()
|
|
388
|
+
openTimer = setTimeout(() => {
|
|
389
|
+
openTimer = null
|
|
390
|
+
if (isDisabled(node, keys.value)) return
|
|
391
|
+
if (isLeaf(node, keys.value)) collapseTo(level)
|
|
392
|
+
else openBranch(level, node)
|
|
393
|
+
}, props.hoverOpenDelay)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function onPanelsEnter() {
|
|
397
|
+
clearCollapseTimer()
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/* Уводя мышь со всего каскада, схлопываем его до ветки выбранного значения. */
|
|
401
|
+
function onPanelsLeave() {
|
|
402
|
+
clearOpenTimer()
|
|
403
|
+
if (mode.value !== 'columns-hover') return
|
|
404
|
+
clearCollapseTimer()
|
|
405
|
+
collapseTimer = setTimeout(() => {
|
|
406
|
+
collapseTimer = null
|
|
407
|
+
activePath.value = selectedAncestorKeys.value
|
|
408
|
+
}, props.hoverCloseDelay)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// -- Клавиатура ------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
const focused = ref<{ level: number, key: unknown } | null>(null)
|
|
414
|
+
const panelRefs = ref<Record<number, { focusItem: (key: unknown) => void } | null>>({})
|
|
415
|
+
|
|
416
|
+
function setPanelRef(el: unknown, level: number) {
|
|
417
|
+
if (el == null) delete panelRefs.value[level]
|
|
418
|
+
else panelRefs.value[level] = el as { focusItem: (key: unknown) => void }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function enabledRows(level: number): T[] {
|
|
422
|
+
return (panels.value[level] ?? []).filter(node => !isDisabled(node, keys.value))
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function firstEnabledKey(level: number): unknown {
|
|
426
|
+
const rows = enabledRows(level)
|
|
427
|
+
return rows.length ? nodeKey(rows[0], keys.value) : undefined
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function focusRow(level: number, key: unknown) {
|
|
431
|
+
if (key === undefined || isDrawerMode.value) return
|
|
432
|
+
focused.value = { level, key }
|
|
433
|
+
nextTick(() => panelRefs.value[level]?.focusItem(key))
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function nodeByKey(level: number, key: unknown): T | undefined {
|
|
437
|
+
return (panels.value[level] ?? []).find(node => isEqual(nodeKey(node, keys.value), key))
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function moveWithin(level: number, delta: number) {
|
|
441
|
+
const rows = enabledRows(level)
|
|
442
|
+
if (!rows.length) return
|
|
443
|
+
const current = rows.findIndex(node => isEqual(nodeKey(node, keys.value), focused.value?.key))
|
|
444
|
+
const next = current === -1
|
|
445
|
+
? (delta > 0 ? 0 : rows.length - 1)
|
|
446
|
+
: (current + delta + rows.length) % rows.length
|
|
447
|
+
focusRow(level, nodeKey(rows[next], keys.value))
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function focusInitial() {
|
|
451
|
+
if (isDrawerMode.value) return
|
|
452
|
+
const entry = selectedEntry.value
|
|
453
|
+
if (entry) {
|
|
454
|
+
focusRow(entry.level, selectedKey.value)
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
focusRow(0, firstEnabledKey(0))
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function closeAndRestoreFocus() {
|
|
461
|
+
toggleOpen(false)
|
|
462
|
+
nextTick(() => reference.value?.focus())
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function onKeydown(event: KeyboardEvent) {
|
|
466
|
+
if (props.disabled) return
|
|
467
|
+
|
|
468
|
+
if (!isOpen.value) {
|
|
469
|
+
if (event.key === 'ArrowDown') {
|
|
470
|
+
event.preventDefault()
|
|
471
|
+
toggleOpen(true)
|
|
472
|
+
}
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (event.key === 'Escape') {
|
|
477
|
+
event.preventDefault()
|
|
478
|
+
closeAndRestoreFocus()
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (event.key === 'Tab') {
|
|
483
|
+
toggleOpen(false)
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (isDrawerMode.value) return
|
|
488
|
+
|
|
489
|
+
const level = focused.value?.level ?? 0
|
|
490
|
+
|
|
491
|
+
switch (event.key) {
|
|
492
|
+
case 'ArrowDown':
|
|
493
|
+
event.preventDefault()
|
|
494
|
+
moveWithin(level, 1)
|
|
495
|
+
break
|
|
496
|
+
case 'ArrowUp':
|
|
497
|
+
event.preventDefault()
|
|
498
|
+
moveWithin(level, -1)
|
|
499
|
+
break
|
|
500
|
+
case 'Home':
|
|
501
|
+
event.preventDefault()
|
|
502
|
+
focusRow(level, firstEnabledKey(level))
|
|
503
|
+
break
|
|
504
|
+
case 'End': {
|
|
505
|
+
event.preventDefault()
|
|
506
|
+
const rows = enabledRows(level)
|
|
507
|
+
if (rows.length) focusRow(level, nodeKey(rows[rows.length - 1], keys.value))
|
|
508
|
+
break
|
|
509
|
+
}
|
|
510
|
+
case 'ArrowRight': {
|
|
511
|
+
const node = focused.value ? nodeByKey(level, focused.value.key) : undefined
|
|
512
|
+
if (!node || isLeaf(node, keys.value)) break
|
|
513
|
+
event.preventDefault()
|
|
514
|
+
openBranch(level, node)
|
|
515
|
+
focusRow(level + 1, firstEnabledKey(level + 1))
|
|
516
|
+
break
|
|
517
|
+
}
|
|
518
|
+
case 'ArrowLeft': {
|
|
519
|
+
if (level === 0) break
|
|
520
|
+
event.preventDefault()
|
|
521
|
+
const parentKey = resolvedPath.value[level - 1]
|
|
522
|
+
goBackFrom(level)
|
|
523
|
+
focusRow(level - 1, parentKey)
|
|
524
|
+
break
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// -- Открытие / закрытие ---------------------------------------------------
|
|
530
|
+
|
|
531
|
+
watch(isOpen, (open) => {
|
|
532
|
+
clearOpenTimer()
|
|
533
|
+
clearCollapseTimer()
|
|
534
|
+
if (open) {
|
|
535
|
+
activePath.value = selectedAncestorKeys.value
|
|
536
|
+
/* Ширину берём с триггера сразу: иначе колонка мигнёт фолбэком min-w-45 и прыгнет. */
|
|
537
|
+
const triggerWidth = reference.value?.offsetWidth
|
|
538
|
+
if (triggerWidth) autoPanelWidth.value = `${Math.round(triggerWidth)}px`
|
|
539
|
+
nextTick(focusInitial)
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
focused.value = null
|
|
543
|
+
}
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
onClickOutside(floating, () => {
|
|
547
|
+
if (isOpen.value && !isDrawerMode.value) toggleOpen(false)
|
|
548
|
+
}, { ignore: [reference] })
|
|
549
|
+
|
|
550
|
+
// -- Отображение в триггере ------------------------------------------------
|
|
551
|
+
|
|
552
|
+
const displayValue = computed(() => {
|
|
553
|
+
const entry = selectedEntry.value
|
|
554
|
+
if (!entry) return ''
|
|
555
|
+
return props.showPathInTrigger
|
|
556
|
+
? pathLabel(entry.path, keys.value)
|
|
557
|
+
: nodeLabel(entry.node, keys.value)
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
const rootPanelTitle = computed(() => props.panelTitle ?? props.label)
|
|
561
|
+
</script>
|
|
562
|
+
|
|
563
|
+
<template>
|
|
564
|
+
<div
|
|
565
|
+
ref="wrapper"
|
|
566
|
+
class="cascade-select relative w-full text-body-md"
|
|
567
|
+
@keydown="onKeydown"
|
|
568
|
+
>
|
|
569
|
+
<a-select-trigger
|
|
570
|
+
ref="triggerRef"
|
|
571
|
+
:label="label"
|
|
572
|
+
:required="required"
|
|
573
|
+
:size="size"
|
|
574
|
+
:disabled="disabled"
|
|
575
|
+
:error="error"
|
|
576
|
+
:is-open="isOpen"
|
|
577
|
+
:start-icon="startIcon"
|
|
578
|
+
:button-bg="buttonBg"
|
|
579
|
+
:clearable="clearable"
|
|
580
|
+
:show-clear="clearable && !!selectedEntry"
|
|
581
|
+
floating-label
|
|
582
|
+
:has-value="!!selectedEntry"
|
|
583
|
+
@toggle="toggleOpen()"
|
|
584
|
+
@clear="onClear"
|
|
585
|
+
>
|
|
586
|
+
<span v-if="selectedEntry" class="block truncate leading-tight">
|
|
587
|
+
<slot
|
|
588
|
+
name="single-selected"
|
|
589
|
+
:value="selectedEntry.node"
|
|
590
|
+
:path="selectedEntry.path"
|
|
591
|
+
>{{ displayValue }}</slot>
|
|
592
|
+
</span>
|
|
593
|
+
<span v-else class="text-secondary">​</span>
|
|
594
|
+
</a-select-trigger>
|
|
595
|
+
|
|
596
|
+
<client-only>
|
|
597
|
+
<teleport
|
|
598
|
+
to="body"
|
|
599
|
+
:disabled="!teleport"
|
|
600
|
+
>
|
|
601
|
+
<transition
|
|
602
|
+
enter-active-class="cascade-select__enter-active"
|
|
603
|
+
enter-from-class="cascade-select__enter-from"
|
|
604
|
+
enter-to-class="cascade-select__enter-to"
|
|
605
|
+
leave-active-class="cascade-select__leave-active"
|
|
606
|
+
leave-from-class="cascade-select__leave-from"
|
|
607
|
+
leave-to-class="cascade-select__leave-to"
|
|
608
|
+
>
|
|
609
|
+
<div
|
|
610
|
+
v-if="isOpen && !isDrawerMode"
|
|
611
|
+
ref="floating"
|
|
612
|
+
class="cascade-select__panels flex items-start gap-2"
|
|
613
|
+
:class="{ 'flex-row-reverse': panelsDir === 'left' }"
|
|
614
|
+
:style="{
|
|
615
|
+
position: strategy,
|
|
616
|
+
left: x != null ? `${x}px` : '',
|
|
617
|
+
top: y != null ? `${y}px` : '',
|
|
618
|
+
zIndex,
|
|
619
|
+
}"
|
|
620
|
+
@pointerenter="onPanelsEnter"
|
|
621
|
+
@pointerleave="onPanelsLeave"
|
|
622
|
+
@keydown="onKeydown"
|
|
623
|
+
>
|
|
624
|
+
<a-cascade-panel
|
|
625
|
+
v-for="view in visiblePanelViews"
|
|
626
|
+
:key="view.level"
|
|
627
|
+
:ref="(el: any) => setPanelRef(el, view.level)"
|
|
628
|
+
:options="view.options"
|
|
629
|
+
:keys="keys"
|
|
630
|
+
:level="view.level"
|
|
631
|
+
:active-key="resolvedPath[view.level]"
|
|
632
|
+
:selected-key="selectedKey"
|
|
633
|
+
:focused-key="focused?.level === view.level ? focused?.key : undefined"
|
|
634
|
+
:title="view.level === 0 ? rootPanelTitle : ''"
|
|
635
|
+
:show-back="showBackFor(view.level)"
|
|
636
|
+
:mirrored="panelsDir === 'left'"
|
|
637
|
+
:back-label="backLabel"
|
|
638
|
+
:width="panelWidthStyle"
|
|
639
|
+
:min-width="panelMinWidthStyle"
|
|
640
|
+
:max-height="panelMaxHeightStyle"
|
|
641
|
+
:empty-label="emptyLabel"
|
|
642
|
+
@activate="activate(view.level, $event)"
|
|
643
|
+
@hover="onRowHover(view.level, $event)"
|
|
644
|
+
@back="goBackFrom(view.level)"
|
|
645
|
+
>
|
|
646
|
+
<template #option="slotProps">
|
|
647
|
+
<slot name="option" v-bind="slotProps" />
|
|
648
|
+
</template>
|
|
649
|
+
<template #empty>
|
|
650
|
+
<slot name="empty" />
|
|
651
|
+
</template>
|
|
652
|
+
</a-cascade-panel>
|
|
653
|
+
</div>
|
|
654
|
+
</transition>
|
|
655
|
+
</teleport>
|
|
656
|
+
|
|
657
|
+
<a-cascade-drawer
|
|
658
|
+
v-if="isDrawerMode"
|
|
659
|
+
v-model="isOpen"
|
|
660
|
+
:options="drawerView.options"
|
|
661
|
+
:keys="keys"
|
|
662
|
+
:level="drawerView.level"
|
|
663
|
+
:title="drawerTitle ?? label"
|
|
664
|
+
:parent-label="drawerParentLabel"
|
|
665
|
+
:back-label="backLabel"
|
|
666
|
+
:selected-key="selectedKey"
|
|
667
|
+
:height="drawerHeightStyle"
|
|
668
|
+
:max-height="drawerMaxHeightStyle"
|
|
669
|
+
:z-index="zIndex"
|
|
670
|
+
@activate="activate(drawerView.level, $event)"
|
|
671
|
+
@back="goBack"
|
|
672
|
+
>
|
|
673
|
+
<template #option="slotProps">
|
|
674
|
+
<slot name="option" v-bind="slotProps" />
|
|
675
|
+
</template>
|
|
676
|
+
<template #empty>
|
|
677
|
+
<slot name="empty" />
|
|
678
|
+
</template>
|
|
679
|
+
</a-cascade-drawer>
|
|
680
|
+
</client-only>
|
|
681
|
+
</div>
|
|
682
|
+
</template>
|
|
683
|
+
|
|
684
|
+
<style scoped>
|
|
685
|
+
.cascade-select__enter-active,
|
|
686
|
+
.cascade-select__leave-active {
|
|
687
|
+
transition:
|
|
688
|
+
opacity 180ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
689
|
+
transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
.cascade-select__enter-from,
|
|
693
|
+
.cascade-select__leave-to {
|
|
694
|
+
opacity: 0;
|
|
695
|
+
transform: translateY(-4px);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
.cascade-select__enter-to,
|
|
699
|
+
.cascade-select__leave-from {
|
|
700
|
+
opacity: 1;
|
|
701
|
+
transform: translateY(0);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
@media (prefers-reduced-motion: reduce) {
|
|
705
|
+
.cascade-select__enter-active,
|
|
706
|
+
.cascade-select__leave-active {
|
|
707
|
+
transition: none;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
</style>
|