adata-ui 2.1.41-beta.19 → 2.1.41-beta.20

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.
@@ -39,7 +39,7 @@ onBeforeMount(() => {
39
39
  v-show="isDark"
40
40
  :href="bannerData?.redirect_url"
41
41
  target="_blank"
42
- :class="location === 'horizontal' ? '' : 'mt-4 hidden w-0 min-w-full lg:flex'"
42
+ :class="location === 'horizontal' ? 'hidden lg:block' : 'mt-4 hidden w-0 min-w-full lg:flex'"
43
43
  >
44
44
  <img
45
45
  class="w-full"
@@ -51,7 +51,7 @@ onBeforeMount(() => {
51
51
  v-show="!isDark"
52
52
  :href="bannerData?.redirect_url"
53
53
  target="_blank"
54
- :class="location === 'horizontal' ? '' : 'mt-4 hidden w-0 min-w-full lg:flex'"
54
+ :class="location === 'horizontal' ? 'hidden lg:block' : 'mt-4 hidden w-0 min-w-full lg:flex'"
55
55
  >
56
56
  <img
57
57
  class="w-full"
@@ -127,7 +127,7 @@ const ROUNDED_CLASS: Record<Rounded, string> = {
127
127
  const ICON_SIZE: Record<Size, string> = {
128
128
  xs: 'size-3.5',
129
129
  sm: 'size-4',
130
- md: 'size-5',
130
+ md: 'size-4.5',
131
131
  lg: 'size-5',
132
132
  }
133
133
 
@@ -0,0 +1,592 @@
1
+ <script generic="T" lang="ts" setup>
2
+ import type { Placement } from '@floating-ui/vue'
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, useToggle } from '@vueuse/core'
6
+ import { isEqual } from 'lodash-es'
7
+
8
+ /**
9
+ * Селект по двухуровневому справочнику: категория и вложенные в неё виды.
10
+ *
11
+ * Отличие от `ACascadeSelectV2` — тот показывает уровни отдельными колонками и даёт
12
+ * выбрать ТОЛЬКО лист. Здесь оба уровня лежат в одном списке и категория выбирается
13
+ * наравне с дочерним видом (`selectableParents`): справочники вроде «вида нарушения»
14
+ * допускают и общую категорию, и уточнение.
15
+ *
16
+ * На вход идёт ПЛОСКИЙ список — ровно в том виде, в каком его отдаёт бэк
17
+ * (`[{ id, name, parent_id }]`). Группировку компонент делает сам, поэтому
18
+ * консьюмерам не нужно раскладывать справочник в дерево у себя.
19
+ *
20
+ * Весь «хром» триггера — общий `a-select-trigger`, тот же, что у `ASelectV2`,
21
+ * поэтому поле выглядит идентично обычному селекту.
22
+ */
23
+ defineOptions({ name: 'AGroupSelectV2' })
24
+
25
+ const props = withDefaults(defineProps<Props>(), {
26
+ required: false,
27
+ keyToShow: 'name',
28
+ keyToSelect: 'id',
29
+ parentKey: 'parent_id',
30
+ disabledKey: undefined,
31
+ getOnlyKey: false,
32
+ selectableParents: true,
33
+ clearable: true,
34
+ disabled: false,
35
+ size: 'sm',
36
+ error: undefined,
37
+ startIcon: undefined,
38
+ buttonBg: '',
39
+ searchable: false,
40
+ searchLabel: 'Поиск...',
41
+ showPathInTrigger: false,
42
+ dropdownWidth: undefined,
43
+ dropdownMaxHeight: undefined,
44
+ dropdownAlign: 'start',
45
+ emptyLabel: 'Ничего не найдено',
46
+ })
47
+
48
+ const emits = defineEmits<Emits>()
49
+
50
+ defineSlots<{
51
+ 'option'?: (props: { option: T, isParent: boolean, selected: boolean }) => unknown
52
+ 'single-selected'?: (props: { value: T, parent: T | null }) => unknown
53
+ 'empty'?: () => unknown
54
+ }>()
55
+
56
+ interface Props {
57
+ label: string
58
+ /** Плоский список: вложенность задаётся через `parentKey`, а не массивом детей. */
59
+ options: T[]
60
+ required?: boolean
61
+ keyToShow?: string
62
+ keyToSelect?: string
63
+ /** Ключ со ссылкой на родителя. `null`/`undefined` — элемент верхнего уровня. */
64
+ parentKey?: string
65
+ /** Необязательный ключ-флаг «опция недоступна». */
66
+ disabledKey?: string
67
+ /** `true` — в v-model лежит ключ опции, `false` — сам объект. */
68
+ getOnlyKey?: boolean
69
+ /** Можно ли выбрать саму категорию, а не только вложенный вид. */
70
+ selectableParents?: boolean
71
+ clearable?: boolean
72
+ disabled?: boolean
73
+ size?: 'sm' | 'md'
74
+ error?: string | string[]
75
+ startIcon?: object
76
+ buttonBg?: string
77
+ searchable?: boolean
78
+ searchLabel?: string
79
+ /** Показывать в триггере весь путь («Категория / Вид»). */
80
+ showPathInTrigger?: boolean
81
+ dropdownWidth?: number | string
82
+ dropdownMaxHeight?: number | string
83
+ dropdownAlign?: 'start' | 'center' | 'end'
84
+ emptyLabel?: string
85
+ }
86
+
87
+ interface Emits {
88
+ (e: 'onChange', option: T, parent: T | null): void
89
+ (e: 'onClear'): void
90
+ }
91
+
92
+ const modelValue = defineModel<T | unknown | null>({ required: true })
93
+ const isOpen = defineModel<boolean>('open', { default: false })
94
+
95
+ function readKey(node: T, key: string): unknown {
96
+ return (node as Record<string, unknown>)[key]
97
+ }
98
+
99
+ function nodeKey(node: T): unknown {
100
+ return readKey(node, props.keyToSelect)
101
+ }
102
+
103
+ function nodeLabel(node: T): string {
104
+ return String(readKey(node, props.keyToShow) ?? '')
105
+ }
106
+
107
+ function nodeDisabled(node: T): boolean {
108
+ return props.disabledKey ? Boolean(readKey(node, props.disabledKey)) : false
109
+ }
110
+
111
+ // -- Группировка -----------------------------------------------------------
112
+
113
+ interface Group {
114
+ parent: T
115
+ children: T[]
116
+ }
117
+
118
+ /**
119
+ * Один проход по плоскому списку. Элемент считается дочерним, только если его
120
+ * `parent_id` реально указывает на существующий верхнеуровневый элемент —
121
+ * «сирота» со ссылкой в никуда поднимается в корень, а не теряется из списка.
122
+ */
123
+ const groups = computed<Group[]>(() => {
124
+ const items = props.options ?? []
125
+ if (!items.length) return []
126
+
127
+ const rootKeys = new Set(
128
+ items.filter(item => readKey(item, props.parentKey) == null).map(item => nodeKey(item)),
129
+ )
130
+
131
+ const childrenByParent = new Map<unknown, T[]>()
132
+ const roots: T[] = []
133
+
134
+ items.forEach((item) => {
135
+ const parentId = readKey(item, props.parentKey)
136
+ if (parentId != null && rootKeys.has(parentId)) {
137
+ const siblings = childrenByParent.get(parentId) ?? []
138
+ siblings.push(item)
139
+ childrenByParent.set(parentId, siblings)
140
+ return
141
+ }
142
+ roots.push(item)
143
+ })
144
+
145
+ return roots.map(root => ({ parent: root, children: childrenByParent.get(nodeKey(root)) ?? [] }))
146
+ })
147
+
148
+ /** Плоский индекс «ключ → узел и его родитель» — по нему решается отображение в триггере. */
149
+ const index = computed(() => {
150
+ const map = new Map<unknown, { node: T, parent: T | null }>()
151
+ groups.value.forEach(({ parent, children }) => {
152
+ map.set(nodeKey(parent), { node: parent, parent: null })
153
+ children.forEach(child => map.set(nodeKey(child), { node: child, parent }))
154
+ })
155
+ return map
156
+ })
157
+
158
+ // -- Поиск -----------------------------------------------------------------
159
+
160
+ const searchValue = ref('')
161
+
162
+ /**
163
+ * Группа остаётся в списке, если совпал сам заголовок (тогда показываем её целиком)
164
+ * или хоть один вложенный вид (тогда заголовок остаётся как контекст, даже если
165
+ * сам не совпал — иначе непонятно, к чему относится найденный вид).
166
+ */
167
+ const visibleGroups = computed<Group[]>(() => {
168
+ const needle = searchValue.value.trim().toLowerCase()
169
+ if (!needle) return groups.value
170
+
171
+ return groups.value.flatMap(({ parent, children }) => {
172
+ if (nodeLabel(parent).toLowerCase().includes(needle)) return [{ parent, children }]
173
+ const matched = children.filter(child => nodeLabel(child).toLowerCase().includes(needle))
174
+ return matched.length ? [{ parent, children: matched }] : []
175
+ })
176
+ })
177
+
178
+ const isEmpty = computed(() => visibleGroups.value.length === 0)
179
+
180
+ // -- Выбранное значение ----------------------------------------------------
181
+
182
+ const selectedKey = computed<unknown>(() => {
183
+ const value = modelValue.value
184
+ if (value == null || value === '') return undefined
185
+ return props.getOnlyKey ? value : nodeKey(value as T)
186
+ })
187
+
188
+ const selectedEntry = computed(() =>
189
+ selectedKey.value === undefined ? null : index.value.get(selectedKey.value) ?? null,
190
+ )
191
+
192
+ const displayValue = computed(() => {
193
+ const entry = selectedEntry.value
194
+ if (!entry) return ''
195
+ if (props.showPathInTrigger && entry.parent) {
196
+ return `${nodeLabel(entry.parent)} / ${nodeLabel(entry.node)}`
197
+ }
198
+ return nodeLabel(entry.node)
199
+ })
200
+
201
+ function isSelected(node: T): boolean {
202
+ return selectedKey.value !== undefined && isEqual(selectedKey.value, nodeKey(node))
203
+ }
204
+
205
+ /** Заголовок группы кликабелен, только если категории вообще разрешено выбирать. */
206
+ function isParentSelectable(parent: T): boolean {
207
+ return props.selectableParents && !nodeDisabled(parent)
208
+ }
209
+
210
+ // -- Позиционирование ------------------------------------------------------
211
+
212
+ const wrapper = ref<HTMLElement | null>(null)
213
+ const triggerRef = ref<{ el: HTMLButtonElement | null, valueEl: HTMLElement | null } | null>(null)
214
+ const reference = computed<HTMLElement | null>(() => triggerRef.value?.el ?? null)
215
+ const floating = ref<HTMLElement | null>(null)
216
+
217
+ const dropdownPlacement = computed<Placement>(() => {
218
+ if (props.dropdownAlign === 'center') return 'bottom'
219
+ if (props.dropdownAlign === 'end') return 'bottom-end'
220
+ return 'bottom-start'
221
+ })
222
+
223
+ const dropdownFallback = computed<Placement[]>(() => {
224
+ if (props.dropdownAlign === 'center') return ['top']
225
+ if (props.dropdownAlign === 'end') return ['top-end']
226
+ return ['top-start']
227
+ })
228
+
229
+ const { x, y, strategy, update } = useFloating(reference, floating, {
230
+ placement: dropdownPlacement,
231
+ strategy: 'absolute',
232
+ middleware: computed(() => [
233
+ offset(6),
234
+ flip({ fallbackPlacements: dropdownFallback.value }),
235
+ shift({ padding: 8 }),
236
+ floatingSize({
237
+ apply({ availableHeight, elements, rects }) {
238
+ const el = elements.floating as HTMLElement
239
+ el.style.width = toCssSize(props.dropdownWidth) ?? `${rects.reference.width}px`
240
+ const maxHeightOverride = toCssSize(props.dropdownMaxHeight)
241
+ el.style.maxHeight = maxHeightOverride
242
+ ? `min(${maxHeightOverride}, ${availableHeight}px)`
243
+ : `${Math.max(140, Math.min(availableHeight, 320))}px`
244
+ },
245
+ }),
246
+ ]),
247
+ })
248
+
249
+ let cleanup: (() => void) | null = null
250
+ watch(isOpen, (open) => {
251
+ if (open && reference.value && floating.value) {
252
+ cleanup = autoUpdate(reference.value, floating.value, update)
253
+ }
254
+ else {
255
+ cleanup?.()
256
+ cleanup = null
257
+ }
258
+ })
259
+ onUnmounted(() => cleanup?.())
260
+
261
+ const toggleOpen = useToggle(isOpen)
262
+
263
+ onClickOutside(wrapper, () => {
264
+ if (isOpen.value) toggleOpen(false)
265
+ })
266
+
267
+ // -- Действия --------------------------------------------------------------
268
+
269
+ function onSelect(node: T) {
270
+ if (nodeDisabled(node)) return
271
+ modelValue.value = props.getOnlyKey ? nodeKey(node) : node
272
+ emits('onChange', node, index.value.get(nodeKey(node))?.parent ?? null)
273
+ toggleOpen(false)
274
+ }
275
+
276
+ function onClear() {
277
+ modelValue.value = null
278
+ emits('onClear')
279
+ }
280
+
281
+ defineExpose({ onClear })
282
+
283
+ // -- Клавиатура ------------------------------------------------------------
284
+
285
+ /** Строки в порядке отрисовки — только те, на которые реально можно встать. */
286
+ const navigableRows = computed<T[]>(() =>
287
+ visibleGroups.value.flatMap(({ parent, children }) => [
288
+ ...(isParentSelectable(parent) ? [parent] : []),
289
+ ...children.filter(child => !nodeDisabled(child)),
290
+ ]),
291
+ )
292
+
293
+ const focusedKey = ref<unknown>(undefined)
294
+ const listRef = ref<HTMLElement | null>(null)
295
+
296
+ function focusRow(key: unknown) {
297
+ focusedKey.value = key
298
+ nextTick(() => {
299
+ listRef.value
300
+ ?.querySelector<HTMLElement>(`[data-row-key="${String(key)}"]`)
301
+ ?.scrollIntoView({ block: 'nearest' })
302
+ })
303
+ }
304
+
305
+ function moveFocus(delta: number) {
306
+ const rows = navigableRows.value
307
+ if (!rows.length) return
308
+ const current = rows.findIndex(row => isEqual(nodeKey(row), focusedKey.value))
309
+ const next = current === -1
310
+ ? (delta > 0 ? 0 : rows.length - 1)
311
+ : (current + delta + rows.length) % rows.length
312
+ focusRow(nodeKey(rows[next]))
313
+ }
314
+
315
+ function onKeydown(event: KeyboardEvent) {
316
+ if (props.disabled) return
317
+
318
+ if (!isOpen.value) {
319
+ if (event.key === 'ArrowDown') {
320
+ event.preventDefault()
321
+ toggleOpen(true)
322
+ }
323
+ return
324
+ }
325
+
326
+ switch (event.key) {
327
+ case 'Escape':
328
+ event.preventDefault()
329
+ toggleOpen(false)
330
+ nextTick(() => reference.value?.focus())
331
+ break
332
+ case 'Tab':
333
+ toggleOpen(false)
334
+ break
335
+ case 'ArrowDown':
336
+ event.preventDefault()
337
+ moveFocus(1)
338
+ break
339
+ case 'ArrowUp':
340
+ event.preventDefault()
341
+ moveFocus(-1)
342
+ break
343
+ case 'Home':
344
+ event.preventDefault()
345
+ if (navigableRows.value.length) focusRow(nodeKey(navigableRows.value[0]))
346
+ break
347
+ case 'End': {
348
+ event.preventDefault()
349
+ const rows = navigableRows.value
350
+ if (rows.length) focusRow(nodeKey(rows[rows.length - 1]))
351
+ break
352
+ }
353
+ case 'Enter': {
354
+ const row = navigableRows.value.find(item => isEqual(nodeKey(item), focusedKey.value))
355
+ if (!row) break
356
+ event.preventDefault()
357
+ onSelect(row)
358
+ break
359
+ }
360
+ }
361
+ }
362
+
363
+ /* Открытие — на выбранном значении, закрытие — сброс поиска и фокуса. */
364
+ watch(isOpen, (open) => {
365
+ if (open) {
366
+ focusedKey.value = selectedKey.value ?? nodeKey(navigableRows.value[0] ?? ({} as T))
367
+ nextTick(() => focusRow(focusedKey.value))
368
+ return
369
+ }
370
+ searchValue.value = ''
371
+ focusedKey.value = undefined
372
+ })
373
+ </script>
374
+
375
+ <template>
376
+ <div
377
+ ref="wrapper"
378
+ class="group-select relative w-full text-body-md"
379
+ @keydown="onKeydown"
380
+ >
381
+ <a-select-trigger
382
+ ref="triggerRef"
383
+ :label="label"
384
+ :required="required"
385
+ :size="size"
386
+ :disabled="disabled"
387
+ :error="error"
388
+ :is-open="isOpen"
389
+ :start-icon="startIcon"
390
+ :button-bg="buttonBg"
391
+ :clearable="clearable"
392
+ :show-clear="clearable && !!selectedEntry"
393
+ floating-label
394
+ :has-value="!!selectedEntry"
395
+ @toggle="toggleOpen()"
396
+ @clear="onClear"
397
+ >
398
+ <span v-if="selectedEntry" class="block truncate leading-tight">
399
+ <slot
400
+ name="single-selected"
401
+ :value="selectedEntry.node"
402
+ :parent="selectedEntry.parent"
403
+ >{{ displayValue }}</slot>
404
+ </span>
405
+ <span v-else class="text-secondary">&#8203;</span>
406
+ </a-select-trigger>
407
+
408
+ <transition
409
+ enter-active-class="group-select__dropdown-enter-active"
410
+ enter-from-class="group-select__dropdown-enter-from"
411
+ enter-to-class="group-select__dropdown-enter-to"
412
+ leave-active-class="group-select__dropdown-leave-active"
413
+ leave-from-class="group-select__dropdown-leave-from"
414
+ leave-to-class="group-select__dropdown-leave-to"
415
+ >
416
+ <div
417
+ v-if="isOpen"
418
+ ref="floating"
419
+ class="group-select__dropdown z-10000 rounded-lg border border-default bg-main shadow-lg shadow-surface-dark/10 dark:shadow-surface-dark/30"
420
+ :style="{
421
+ position: strategy,
422
+ left: x != null ? `${x}px` : '',
423
+ top: y != null ? `${y}px` : '',
424
+ }"
425
+ >
426
+ <div v-if="searchable" class="px-2.5 pt-2.5">
427
+ <a-input-standard
428
+ v-model="searchValue"
429
+ :label="searchLabel"
430
+ :size="size"
431
+ clearable
432
+ color="gray"
433
+ />
434
+ </div>
435
+
436
+ <ul
437
+ ref="listRef"
438
+ class="group-select__list flex flex-col gap-0.5 p-1.5"
439
+ role="listbox"
440
+ >
441
+ <li v-if="isEmpty" class="px-3 py-6 text-center text-body-sm text-quaternary">
442
+ <slot name="empty">
443
+ {{ emptyLabel }}
444
+ </slot>
445
+ </li>
446
+
447
+ <li
448
+ v-for="group in visibleGroups"
449
+ :key="String(nodeKey(group.parent))"
450
+ >
451
+ <!-- Категория: кнопка, когда её разрешено выбирать, иначе просто заголовок. -->
452
+ <component
453
+ :is="isParentSelectable(group.parent) ? 'button' : 'div'"
454
+ :data-row-key="String(nodeKey(group.parent))"
455
+ :type="isParentSelectable(group.parent) ? 'button' : undefined"
456
+ :role="isParentSelectable(group.parent) ? 'option' : 'presentation'"
457
+ :aria-selected="isParentSelectable(group.parent) ? isSelected(group.parent) : undefined"
458
+ :disabled="isParentSelectable(group.parent) ? nodeDisabled(group.parent) : undefined"
459
+ class="flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-start"
460
+ :class="[
461
+ isParentSelectable(group.parent)
462
+ ? 'group-select__row text-body-md-medium text-primary transition-colors duration-100 hover:bg-action-secondary-hover'
463
+ : 'text-overline text-quaternary',
464
+ { 'group-select__row--selected': isSelected(group.parent) },
465
+ { 'group-select__row--focused': isEqual(focusedKey, nodeKey(group.parent)) },
466
+ ]"
467
+ @click="isParentSelectable(group.parent) ? onSelect(group.parent) : undefined"
468
+ >
469
+ <span class="min-w-0 flex-1 truncate">
470
+ <slot
471
+ name="option"
472
+ :option="group.parent"
473
+ :is-parent="true"
474
+ :selected="isSelected(group.parent)"
475
+ >{{ nodeLabel(group.parent) }}</slot>
476
+ </span>
477
+ <a-icon-check
478
+ v-if="isSelected(group.parent)"
479
+ class="size-4 shrink-0 text-brand"
480
+ />
481
+ </component>
482
+
483
+ <!-- Вложенные виды: отступ и точка-маркер, чтобы связь с категорией читалась. -->
484
+ <ul v-if="group.children.length" class="mt-0.5 flex flex-col gap-0.5">
485
+ <li v-for="child in group.children" :key="String(nodeKey(child))">
486
+ <button
487
+ type="button"
488
+ role="option"
489
+ :data-row-key="String(nodeKey(child))"
490
+ :aria-selected="isSelected(child)"
491
+ :disabled="nodeDisabled(child)"
492
+ class="group-select__row flex w-full items-center gap-2 rounded-md py-2 pr-3 pl-6 text-start text-body-md text-secondary transition-colors duration-100 hover:bg-action-secondary-hover"
493
+ :class="[
494
+ { 'group-select__row--selected': isSelected(child) },
495
+ { 'group-select__row--focused': isEqual(focusedKey, nodeKey(child)) },
496
+ ]"
497
+ @click="onSelect(child)"
498
+ >
499
+ <span class="group-select__bullet shrink-0 rounded-full" />
500
+ <span class="min-w-0 flex-1 truncate">
501
+ <slot
502
+ name="option"
503
+ :option="child"
504
+ :is-parent="false"
505
+ :selected="isSelected(child)"
506
+ >{{ nodeLabel(child) }}</slot>
507
+ </span>
508
+ <a-icon-check
509
+ v-if="isSelected(child)"
510
+ class="size-4 shrink-0 text-brand"
511
+ />
512
+ </button>
513
+ </li>
514
+ </ul>
515
+ </li>
516
+ </ul>
517
+ </div>
518
+ </transition>
519
+ </div>
520
+ </template>
521
+
522
+ <style scoped>
523
+ .group-select__dropdown-enter-active,
524
+ .group-select__dropdown-leave-active {
525
+ transition:
526
+ opacity 180ms cubic-bezier(0.22, 1, 0.36, 1),
527
+ transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
528
+ }
529
+
530
+ .group-select__dropdown-enter-from,
531
+ .group-select__dropdown-leave-to {
532
+ opacity: 0;
533
+ transform: translateY(-4px);
534
+ }
535
+
536
+ .group-select__dropdown-enter-to,
537
+ .group-select__dropdown-leave-from {
538
+ opacity: 1;
539
+ transform: translateY(0);
540
+ }
541
+
542
+ .group-select__list {
543
+ max-height: 250px;
544
+ overflow-y: auto;
545
+ overflow-x: hidden;
546
+ scrollbar-width: none;
547
+ -ms-overflow-style: none;
548
+ }
549
+
550
+ .group-select__list::-webkit-scrollbar {
551
+ width: 0;
552
+ height: 0;
553
+ display: none;
554
+ }
555
+
556
+ .group-select__row {
557
+ cursor: pointer;
558
+ }
559
+
560
+ /* Размер и цвет точки — здесь, а не утилитами: consumer может тянуть CSS слоя
561
+ из npm-копии, и утилита, которую больше никто не использует, не сгенерируется. */
562
+ .group-select__bullet {
563
+ width: 6px;
564
+ height: 6px;
565
+ background-color: var(--color-icon-secondary);
566
+ }
567
+
568
+ .group-select__row:disabled {
569
+ cursor: default;
570
+ opacity: 0.5;
571
+ }
572
+
573
+ /* Фокус с клавиатуры: рамка внутрь, чтобы не спорить с соседними строками.
574
+ Цвет сильно разбавлен — сплошной --border-color-focus на узкой строке бьёт по глазам. */
575
+ .group-select__row--focused {
576
+ outline: 1px solid color-mix(in srgb, var(--border-color-focus) 35%, transparent);
577
+ outline-offset: -1px;
578
+ background-color: color-mix(in srgb, var(--border-color-focus) 6%, transparent);
579
+ }
580
+
581
+ .group-select__row--selected {
582
+ background-color: var(--background-color-brand-subtle);
583
+ font-weight: 500;
584
+ }
585
+
586
+ @media (prefers-reduced-motion: reduce) {
587
+ .group-select__dropdown-enter-active,
588
+ .group-select__dropdown-leave-active {
589
+ transition: none;
590
+ }
591
+ }
592
+ </style>
@@ -1,4 +1,6 @@
1
1
  <script lang="ts" setup>
2
+ import { useResizeObserver } from '@vueuse/core'
3
+
2
4
  type BadgeType = 'primary' | 'success' | 'danger' | 'gray' | 'warning'
3
5
  type BadgeView = 'default' | 'inverted' | 'transparent' | 'subtle'
4
6
  type BadgeSize = 'xs' | 'sm' | 'md' | 'lg'
@@ -22,7 +24,7 @@ interface Props {
22
24
  customClasses?: string
23
25
  }
24
26
 
25
- defineOptions({ name: 'AStatusBadge' })
27
+ defineOptions({ name: 'AStatusBadge', inheritAttrs: false })
26
28
 
27
29
  const props = withDefaults(defineProps<Props>(), {
28
30
  type: 'primary',
@@ -100,7 +102,7 @@ const pointSwitchValues: ViewType = {
100
102
 
101
103
  const classes = computed(() =>
102
104
  [
103
- 'text-body-md rounded-2xl select-none inline-flex gap-[5.5px] justify-center items-center border',
105
+ 'text-body-md rounded-2xl select-none inline-flex min-w-0 max-w-full gap-[5.5px] justify-center items-center border',
104
106
  typeSwitchValues[props.view][props.type],
105
107
  props.size === 'xs' ? 'px-2 py-[2px] text-body-sm font-bold' : '',
106
108
  props.size === 'sm' ? 'px-[10px] py-1 text-body-sm font-semibold h-6' : '',
@@ -113,13 +115,46 @@ const pointClasses = computed(() =>
113
115
  'w-1.5 h-1.5 rounded-full shrink-0',
114
116
  pointSwitchValues[props.view][props.type],
115
117
  ])
118
+
119
+ const label = ref<HTMLElement | null>(null)
120
+ const isTruncated = ref(false)
121
+ const tooltipText = ref('')
122
+
123
+ /* Обрезку меряем по самому тексту: ширину бейджу задаёт потребитель
124
+ (max-w/сетка), поэтому ResizeObserver ловит ресайз, а mouseenter — смену
125
+ содержимого слота, при которой размер обрезанного span не меняется. */
126
+ function measure() {
127
+ const el = label.value
128
+ if (!el) return
129
+ isTruncated.value = el.scrollWidth > el.clientWidth + 1
130
+ tooltipText.value = el.textContent?.trim() ?? ''
131
+ }
132
+
133
+ useResizeObserver(label, measure)
134
+ onMounted(() => nextTick(measure))
116
135
  </script>
117
136
 
118
137
  <template>
119
- <div :class="[[classes], customClasses]">
120
- <slot name="point">
121
- <span v-if="point" :class="pointClasses" />
122
- </slot>
123
- <slot />
124
- </div>
138
+ <a-tooltip-v2
139
+ class="max-w-full min-w-0"
140
+ content-class="max-h-[50vh] max-w-[min(20rem,calc(100vw-2rem))] overflow-hidden text-body-sm whitespace-normal text-primary wrap-anywhere"
141
+ :text="tooltipText"
142
+ :disabled="!isTruncated"
143
+ >
144
+ <div
145
+ v-bind="$attrs"
146
+ :class="[[classes], customClasses]"
147
+ @mouseenter="measure"
148
+ >
149
+ <slot name="point">
150
+ <span v-if="point" :class="pointClasses" />
151
+ </slot>
152
+ <span
153
+ ref="label"
154
+ class="truncate"
155
+ >
156
+ <slot />
157
+ </span>
158
+ </div>
159
+ </a-tooltip-v2>
125
160
  </template>
@@ -0,0 +1,197 @@
1
+ <script setup lang="ts">
2
+ import type { Component } from 'vue'
3
+ import { useElementHover } from '@vueuse/core'
4
+ import { computed, ref, watch } from 'vue'
5
+
6
+ export interface StepSliderRowCardStep {
7
+ title: string
8
+ description: string
9
+ icon?: Component
10
+ iconBg?: string
11
+ iconColor?: string
12
+ }
13
+
14
+ type RowBreakpoint = 'sm' | 'md' | 'lg'
15
+
16
+ interface Props {
17
+ steps: StepSliderRowCardStep[]
18
+ interval?: number
19
+ autoplay?: boolean
20
+ pauseOnHover?: boolean
21
+ rowFrom?: RowBreakpoint
22
+ connectors?: boolean
23
+ titleTag?: 'p' | 'h2' | 'h3' | 'h4' | 'h5'
24
+ }
25
+
26
+ const props = withDefaults(defineProps<Props>(), {
27
+ interval: 5000,
28
+ autoplay: true,
29
+ pauseOnHover: true,
30
+ rowFrom: 'lg',
31
+ connectors: true,
32
+ titleTag: 'p',
33
+ })
34
+
35
+ const active = defineModel<number>({ default: 0 })
36
+
37
+ const ROW_LAYOUT: Record<RowBreakpoint, { grid: string, lift: string, connector: string }> = {
38
+ sm: { grid: 'sm:grid-cols-none sm:grid-flow-col sm:auto-cols-fr', lift: 'sm:-translate-y-1.5', connector: 'sm:flex' },
39
+ md: { grid: 'md:grid-cols-none md:grid-flow-col md:auto-cols-fr', lift: 'md:-translate-y-1.5', connector: 'md:flex' },
40
+ lg: { grid: 'lg:grid-cols-none lg:grid-flow-col lg:auto-cols-fr', lift: 'lg:-translate-y-1.5', connector: 'lg:flex' },
41
+ }
42
+
43
+ const root = ref<HTMLElement>()
44
+ const hovered = useElementHover(root)
45
+
46
+ const count = computed(() => props.steps.length)
47
+ const layout = computed(() => ROW_LAYOUT[props.rowFrom])
48
+ const running = computed(() => props.autoplay && count.value > 1)
49
+ const paused = computed(() => props.pauseOnHover && hovered.value)
50
+
51
+ const timingStyle = computed(() => ({
52
+ animationDuration: `${props.interval}ms`,
53
+ animationPlayState: paused.value ? 'paused' : 'running',
54
+ }))
55
+
56
+ function goTo(to: number) {
57
+ if (count.value === 0)
58
+ return
59
+ active.value = ((to % count.value) + count.value) % count.value
60
+ }
61
+
62
+ function next() {
63
+ goTo(active.value + 1)
64
+ }
65
+
66
+ watch(count, (value) => {
67
+ if (active.value >= value)
68
+ goTo(0)
69
+ })
70
+
71
+ defineExpose({ goTo, next })
72
+ </script>
73
+
74
+ <template>
75
+ <ol
76
+ ref="root"
77
+ class="grid grid-cols-1 items-stretch gap-5"
78
+ :class="layout.grid"
79
+ >
80
+ <li
81
+ v-for="(step, i) in steps"
82
+ :key="i"
83
+ class="ss-row-card relative flex cursor-pointer flex-col rounded-xl border bg-main p-6 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-(--border-color-focus)"
84
+ :class="i === active
85
+ ? ['ss-row-card-active border-brand', layout.lift]
86
+ : 'border-default opacity-50'"
87
+ @click="goTo(i)"
88
+ >
89
+ <div class="mb-4 flex items-center justify-between">
90
+ <span
91
+ class="flex size-12 shrink-0 items-center justify-center rounded-xl transition-colors duration-500"
92
+ :class="step.iconBg || (i === active ? 'bg-brand' : 'bg-brand-subtle')"
93
+ >
94
+ <component
95
+ :is="step.icon"
96
+ v-if="step.icon"
97
+ class="size-5.5 transition-colors duration-500"
98
+ :class="step.iconColor || (i === active ? 'text-icon-on-brand' : 'text-icon-brand')"
99
+ />
100
+ </span>
101
+
102
+ <span
103
+ class="text-display-lg leading-none transition-colors duration-500"
104
+ :class="i === active
105
+ ? 'text-brand-200 dark:text-brand-900'
106
+ : 'text-secondary-50 dark:text-secondary-800'"
107
+ aria-hidden="true"
108
+ >
109
+ {{ i + 1 }}
110
+ </span>
111
+ </div>
112
+
113
+ <component :is="titleTag" class="mb-1.5 text-h5 text-primary">
114
+ <button
115
+ type="button"
116
+ class="text-left focus-visible:outline-hidden"
117
+ :aria-current="i === active ? 'step' : undefined"
118
+ @click.stop="goTo(i)"
119
+ >
120
+ {{ step.title }}
121
+ </button>
122
+ </component>
123
+
124
+ <p
125
+ class="text-body-md leading-relaxed text-quaternary"
126
+ :class="{ 'mb-4': $slots.visual }"
127
+ >
128
+ {{ step.description }}
129
+ </p>
130
+
131
+ <div
132
+ v-if="$slots.visual"
133
+ class="mt-auto"
134
+ >
135
+ <slot
136
+ name="visual"
137
+ :step="step"
138
+ :index="i"
139
+ :is-active="i === active"
140
+ :active="active"
141
+ :paused="paused"
142
+ />
143
+ </div>
144
+
145
+ <span
146
+ v-if="i === active && running"
147
+ class="ss-row-prog pointer-events-none absolute inset-x-4 bottom-0 h-[3px] rounded-full bg-linear-to-r from-brand to-brand-400"
148
+ :style="timingStyle"
149
+ aria-hidden="true"
150
+ @animationend="next"
151
+ />
152
+
153
+ <span
154
+ v-if="connectors && i < steps.length - 1"
155
+ class="pointer-events-none absolute top-1/2 right-[-13px] z-10 hidden size-6 -translate-y-1/2 items-center justify-center rounded-full border bg-main transition-colors duration-500"
156
+ :class="[layout.connector, i < active ? 'border-brand text-brand' : 'border-default text-quaternary']"
157
+ aria-hidden="true"
158
+ >
159
+ <a-icon-arrow-right class="size-3.5" />
160
+ </span>
161
+ </li>
162
+ </ol>
163
+ </template>
164
+
165
+ <style scoped>
166
+ .ss-row-card {
167
+ transition: border-color .5s ease-out, box-shadow .5s ease-out, opacity .5s ease-out, translate .5s ease-out;
168
+ }
169
+
170
+ .ss-row-card-active {
171
+ box-shadow: 0 20px 46px -24px color-mix(in srgb, var(--color-brand-500) 55%, transparent);
172
+ }
173
+
174
+ .ss-row-prog {
175
+ transform-origin: left;
176
+ animation: ss-row-prog linear forwards;
177
+ }
178
+
179
+ @keyframes ss-row-prog {
180
+ from {
181
+ transform: scaleX(0);
182
+ }
183
+ to {
184
+ transform: scaleX(1);
185
+ }
186
+ }
187
+
188
+ @media (prefers-reduced-motion: reduce) {
189
+ .ss-row-card {
190
+ transition: none;
191
+ }
192
+
193
+ .ss-row-prog {
194
+ animation: none;
195
+ }
196
+ }
197
+ </style>
@@ -351,7 +351,7 @@ function rowDisplay(item: T): unknown {
351
351
  :is="startIcon"
352
352
  v-if="startIcon"
353
353
  class="shrink-0"
354
- :class="[startIconClass || 'size-4 text-icon-disabled', iconOffsetClass]"
354
+ :class="[startIconClass || 'size-4 text-icon-secondary', iconOffsetClass]"
355
355
  />
356
356
 
357
357
  <input
@@ -16,7 +16,6 @@ const otp = ref(['', '', '', '', '', ''])
16
16
  const otpFormatted = computed(() => {
17
17
  return otp.value.join('')
18
18
  })
19
- const showError = ref(false)
20
19
  const isLoading = ref(false)
21
20
 
22
21
  async function loginUser() {
@@ -72,7 +71,7 @@ async function onConfirm() {
72
71
  confirmSuccessfulModal.value = true
73
72
  }
74
73
  catch (error) {
75
- showError.value = true
74
+ $toast.error(apiErrorMessage(error, t('error.validation')))
76
75
  }
77
76
  finally {
78
77
  isLoading.value = false
@@ -81,7 +80,6 @@ async function onConfirm() {
81
80
 
82
81
  function resetOtp() {
83
82
  otp.value = ['', '', '', '', '', '']
84
- showError.value = false
85
83
  }
86
84
 
87
85
  function onClose() {
@@ -162,7 +160,6 @@ onMounted(() => {
162
160
 
163
161
  <id-otp-input
164
162
  v-model="otp"
165
- v-model:error="showError"
166
163
  @on-completed="onConfirm"
167
164
  />
168
165
 
@@ -323,9 +323,10 @@ function onForgotPassword() {
323
323
  block
324
324
  :icon="social.icon"
325
325
  :data-test-id="`login-form-${social.id}-button`"
326
+ class="max-sm:gap-0"
326
327
  @click="authWithSocial(social.id)"
327
328
  >
328
- {{ social.label }}
329
+ <span class="hidden sm:inline">{{ social.label }}</span>
329
330
  </a-button-v2>
330
331
  </div>
331
332
 
@@ -0,0 +1,23 @@
1
+ <script lang="ts" setup="">
2
+
3
+ </script>
4
+
5
+ <template>
6
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
7
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar-days">
8
+ <path d="M8 2v3"/>
9
+ <path d="M16 2v3"/>
10
+ <rect x="3" y="3" width="18" height="18" rx="2"/>
11
+ <path d="M3 9h18"/>
12
+ <path d="M8 13h.01"/>
13
+ <path d="M12 13h.01"/>
14
+ <path d="M16 13h.01"/>
15
+ <path d="M8 17h.01"/>
16
+ <path d="M12 17h.01"/>
17
+ <path d="M16 17h.01"/>
18
+ </svg>
19
+ </template>
20
+
21
+ <style scoped>
22
+
23
+ </style>
@@ -0,0 +1,15 @@
1
+ <script lang="ts" setup="">
2
+
3
+ </script>
4
+
5
+ <template>
6
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
7
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-folder">
8
+ <path
9
+ d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>
10
+ </svg>
11
+ </template>
12
+
13
+ <style scoped>
14
+
15
+ </style>
@@ -0,0 +1,8 @@
1
+ <template>
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-down">
3
+ <path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>
4
+ <path d="M14 2v5a1 1 0 0 0 1 1h5"/>
5
+ <path d="M12 18v-6"/>
6
+ <path d="m9 15 3 3 3-3"/>
7
+ </svg>
8
+ </template>
@@ -0,0 +1,22 @@
1
+ <script lang="ts" setup="">
2
+
3
+ </script>
4
+
5
+ <template>
6
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
7
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sliders-horizontal">
8
+ <path d="M10 5H3"/>
9
+ <path d="M12 19H3"/>
10
+ <path d="M14 3v4"/>
11
+ <path d="M16 17v4"/>
12
+ <path d="M21 12h-9"/>
13
+ <path d="M21 19h-5"/>
14
+ <path d="M21 5h-7"/>
15
+ <path d="M8 10v4"/>
16
+ <path d="M8 12H3"/>
17
+ </svg>
18
+ </template>
19
+
20
+ <style scoped>
21
+
22
+ </style>
@@ -0,0 +1,5 @@
1
+ <template>
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-funnel">
3
+ <path d="M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z"/>
4
+ </svg>
5
+ </template>
@@ -0,0 +1,25 @@
1
+ <script lang="ts" setup="">
2
+
3
+ </script>
4
+
5
+ <template>
6
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
7
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-qr-code">
8
+ <rect width="5" height="5" x="3" y="3" rx="1"/>
9
+ <rect width="5" height="5" x="16" y="3" rx="1"/>
10
+ <rect width="5" height="5" x="3" y="16" rx="1"/>
11
+ <path d="M21 16h-3a2 2 0 0 0-2 2v3"/>
12
+ <path d="M21 21v.01"/>
13
+ <path d="M12 7v3a2 2 0 0 1-2 2H7"/>
14
+ <path d="M3 12h.01"/>
15
+ <path d="M12 3h.01"/>
16
+ <path d="M12 16v.01"/>
17
+ <path d="M16 12h1"/>
18
+ <path d="M21 12v.01"/>
19
+ <path d="M12 21v-1"/>
20
+ </svg>
21
+ </template>
22
+
23
+ <style scoped>
24
+
25
+ </style>
@@ -0,0 +1,10 @@
1
+ <template>
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-recycle">
3
+ <path d="M7 19H4.815a1.83 1.83 0 0 1-1.57-.881 1.785 1.785 0 0 1-.004-1.784L7.196 9.5"/>
4
+ <path d="M11 19h8.203a1.83 1.83 0 0 0 1.556-.89 1.784 1.784 0 0 0 0-1.775l-1.226-2.12"/>
5
+ <path d="m14 16-3 3 3 3"/>
6
+ <path d="M8.293 13.596 7.196 9.5 3.1 10.598"/>
7
+ <path d="m9.344 5.811 1.093-1.892A1.83 1.83 0 0 1 11.985 3a1.784 1.784 0 0 1 1.546.888l3.943 6.843"/>
8
+ <path d="m13.378 9.633 4.096 1.098 1.097-4.096"/>
9
+ </svg>
10
+ </template>
@@ -0,0 +1,15 @@
1
+ <script lang="ts" setup="">
2
+
3
+ </script>
4
+
5
+ <template>
6
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
7
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-reply">
8
+ <path d="M20 18v-2a4 4 0 0 0-4-4H4"/>
9
+ <path d="m9 17-5-5 5-5"/>
10
+ </svg>
11
+ </template>
12
+
13
+ <style scoped>
14
+
15
+ </style>
@@ -0,0 +1,7 @@
1
+ <template>
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-target">
3
+ <circle cx="12" cy="12" r="10"/>
4
+ <circle cx="12" cy="12" r="6"/>
5
+ <circle cx="12" cy="12" r="2"/>
6
+ </svg>
7
+ </template>
@@ -0,0 +1,16 @@
1
+ <script lang="ts" setup="">
2
+
3
+ </script>
4
+
5
+ <template>
6
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
7
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-upload">
8
+ <path d="M12 3v12"/>
9
+ <path d="m17 8-5-5-5 5"/>
10
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
11
+ </svg>
12
+ </template>
13
+
14
+ <style scoped>
15
+
16
+ </style>
@@ -0,0 +1,11 @@
1
+ <script lang="ts" setup="">
2
+
3
+ </script>
4
+
5
+ <template>
6
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user-x"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="17" x2="22" y1="8" y2="13"/><line x1="22" x2="17" y1="8" y2="13"/></svg>
7
+ </template>
8
+
9
+ <style scoped>
10
+
11
+ </style>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "adata-ui",
3
3
  "type": "module",
4
- "version": "2.1.41-beta.19",
4
+ "version": "2.1.41-beta.20",
5
5
  "main": "./nuxt.config.ts",
6
6
  "scripts": {
7
7
  "dev": "nuxi dev .playground",