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.
@@ -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">&#8203;</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>