@krometra/ui-kit 0.0.0-stage → 0.3.0
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/LICENSE +21 -0
- package/README.md +228 -2
- package/dist/ui-kit.css +2 -0
- package/dist/ui-kit.js +14955 -0
- package/dist/ui-kit.umd.cjs +3 -0
- package/package.json +74 -3
- package/src/base.css +32 -0
- package/src/components/KAlert.vue +65 -0
- package/src/components/KAvatar.vue +67 -0
- package/src/components/KBadge.vue +47 -0
- package/src/components/KButton.vue +98 -0
- package/src/components/KCard.vue +63 -0
- package/src/components/KCheckbox.vue +105 -0
- package/src/components/KCombobox.vue +232 -0
- package/src/components/KCountdown.vue +74 -0
- package/src/components/KDescriptions.vue +40 -0
- package/src/components/KDropdown.vue +111 -0
- package/src/components/KEmpty.vue +39 -0
- package/src/components/KFileUpload.vue +121 -0
- package/src/components/KFilterBar.vue +91 -0
- package/src/components/KIcon.vue +53 -0
- package/src/components/KInput.vue +108 -0
- package/src/components/KMenuItem.vue +56 -0
- package/src/components/KModal.vue +136 -0
- package/src/components/KMoney.vue +44 -0
- package/src/components/KPage.vue +93 -0
- package/src/components/KPagination.vue +113 -0
- package/src/components/KProgress.vue +63 -0
- package/src/components/KRadioGroup.vue +103 -0
- package/src/components/KSegmented.vue +88 -0
- package/src/components/KSelect.vue +231 -0
- package/src/components/KShell.vue +176 -0
- package/src/components/KStat.vue +74 -0
- package/src/components/KSteps.vue +119 -0
- package/src/components/KSwitch.vue +82 -0
- package/src/components/KTable.vue +161 -0
- package/src/components/KTabs.vue +102 -0
- package/src/components/KTextarea.vue +79 -0
- package/src/components/KTimeline.vue +72 -0
- package/src/components/KToast.vue +34 -0
- package/src/components/KToastItem.vue +86 -0
- package/src/components/KTooltip.vue +67 -0
- package/src/components/icon-registry.js +143 -0
- package/src/composables/useToast.js +52 -0
- package/src/format.js +137 -0
- package/src/index.js +84 -0
- package/src/theme.js +417 -0
- package/src/tokens.css +109 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KPagination — постраничная навигация на @zag-js/pagination.
|
|
4
|
+
*
|
|
5
|
+
* Контролируемая: компонент не хранит состояние, а сообщает нужную страницу
|
|
6
|
+
* через update:page. Диапазон страниц сворачивается многоточиями, чтобы ширина
|
|
7
|
+
* не зависела от объёма выборки.
|
|
8
|
+
*
|
|
9
|
+
* <KPagination v-model:page="page" :page-size="20" :total="total" />
|
|
10
|
+
*/
|
|
11
|
+
import * as pagination from '@zag-js/pagination'
|
|
12
|
+
import { normalizeProps, useMachine } from '@zag-js/vue'
|
|
13
|
+
import { computed, useId } from 'vue'
|
|
14
|
+
import KIcon from './KIcon.vue'
|
|
15
|
+
|
|
16
|
+
const props = defineProps({
|
|
17
|
+
page: { type: Number, default: 1 },
|
|
18
|
+
pageSize: { type: Number, default: 20 },
|
|
19
|
+
total: { type: Number, default: 0 },
|
|
20
|
+
siblings: { type: Number, default: 1 },
|
|
21
|
+
showInfo: { type: Boolean, default: true },
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const emit = defineEmits(['update:page'])
|
|
25
|
+
|
|
26
|
+
const service = useMachine(pagination.machine, {
|
|
27
|
+
id: useId(),
|
|
28
|
+
translations: {
|
|
29
|
+
rootLabel: 'Постраничная навигация',
|
|
30
|
+
prevTriggerLabel: 'Назад',
|
|
31
|
+
nextTriggerLabel: 'Вперёд',
|
|
32
|
+
itemLabel: ({ page }) => `Страница ${page}`,
|
|
33
|
+
},
|
|
34
|
+
get count() {
|
|
35
|
+
return props.total
|
|
36
|
+
},
|
|
37
|
+
get pageSize() {
|
|
38
|
+
return props.pageSize || 1
|
|
39
|
+
},
|
|
40
|
+
get siblingCount() {
|
|
41
|
+
return props.siblings
|
|
42
|
+
},
|
|
43
|
+
get page() {
|
|
44
|
+
return props.page
|
|
45
|
+
},
|
|
46
|
+
onPageChange({ page }) {
|
|
47
|
+
if (page !== props.page) emit('update:page', page)
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const api = computed(() => pagination.connect(service, normalizeProps))
|
|
52
|
+
|
|
53
|
+
const rangeFrom = computed(() => (props.total ? api.value.pageRange.start + 1 : 0))
|
|
54
|
+
const rangeTo = computed(() => api.value.pageRange.end)
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<template>
|
|
58
|
+
<nav class="k-pagination" v-bind="api.getRootProps()">
|
|
59
|
+
<span v-if="showInfo" class="k-pagination__info">{{ rangeFrom }}–{{ rangeTo }} из {{ total }}</span>
|
|
60
|
+
<div class="k-pagination__pages">
|
|
61
|
+
<button class="k-pagination__nav" v-bind="api.getPrevTriggerProps()">
|
|
62
|
+
<KIcon name="chevron-left" :size="16" />
|
|
63
|
+
</button>
|
|
64
|
+
<template v-for="(item, i) in api.pages" :key="item.type === 'page' ? item.value : `ellipsis-${i}`">
|
|
65
|
+
<span v-if="item.type === 'ellipsis'" class="k-pagination__gap" v-bind="api.getEllipsisProps({ index: i })">…</span>
|
|
66
|
+
<button v-else class="k-pagination__page" v-bind="api.getItemProps(item)">{{ item.value }}</button>
|
|
67
|
+
</template>
|
|
68
|
+
<button class="k-pagination__nav" v-bind="api.getNextTriggerProps()">
|
|
69
|
+
<KIcon name="chevron-right" :size="16" />
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
</nav>
|
|
73
|
+
</template>
|
|
74
|
+
|
|
75
|
+
<style scoped>
|
|
76
|
+
.k-pagination {
|
|
77
|
+
display: flex;
|
|
78
|
+
align-items: center;
|
|
79
|
+
justify-content: space-between;
|
|
80
|
+
gap: 1rem;
|
|
81
|
+
flex-wrap: wrap;
|
|
82
|
+
padding: 0.75rem 0.875rem;
|
|
83
|
+
border-top: 1px solid var(--k-border);
|
|
84
|
+
}
|
|
85
|
+
.k-pagination__info { font-size: 0.8125rem; color: var(--k-text-muted); }
|
|
86
|
+
.k-pagination__pages { display: flex; align-items: center; gap: 0.25rem; margin-left: auto; }
|
|
87
|
+
.k-pagination__page,
|
|
88
|
+
.k-pagination__nav {
|
|
89
|
+
min-width: var(--k-control-h-sm);
|
|
90
|
+
height: var(--k-control-h-sm);
|
|
91
|
+
padding: 0 0.4rem;
|
|
92
|
+
display: inline-flex;
|
|
93
|
+
align-items: center;
|
|
94
|
+
justify-content: center;
|
|
95
|
+
background: none;
|
|
96
|
+
border: 1px solid transparent;
|
|
97
|
+
border-radius: var(--k-radius-sm);
|
|
98
|
+
font: inherit;
|
|
99
|
+
font-size: 0.875rem;
|
|
100
|
+
color: var(--k-text-muted);
|
|
101
|
+
cursor: pointer;
|
|
102
|
+
}
|
|
103
|
+
.k-pagination__page:hover,
|
|
104
|
+
.k-pagination__nav:hover:not(:disabled) { background: var(--k-surface-muted); color: var(--k-text); }
|
|
105
|
+
.k-pagination__page[data-selected] {
|
|
106
|
+
background: var(--k-primary-soft);
|
|
107
|
+
border-color: currentColor;
|
|
108
|
+
color: var(--k-primary);
|
|
109
|
+
font-weight: 600;
|
|
110
|
+
}
|
|
111
|
+
.k-pagination__nav:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
112
|
+
.k-pagination__gap { padding: 0 0.25rem; color: var(--k-text-faint); }
|
|
113
|
+
</style>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KProgress — линейный индикатор на @zag-js/progress: заполненность, ход
|
|
4
|
+
* операции, доля от нормы. `value: null` — неопределённое состояние.
|
|
5
|
+
*/
|
|
6
|
+
import * as progress from '@zag-js/progress'
|
|
7
|
+
import { normalizeProps, useMachine } from '@zag-js/vue'
|
|
8
|
+
import { computed, useId } from 'vue'
|
|
9
|
+
|
|
10
|
+
const props = defineProps({
|
|
11
|
+
value: { type: Number, default: 0 },
|
|
12
|
+
max: { type: Number, default: 100 },
|
|
13
|
+
label: { type: String, default: '' },
|
|
14
|
+
tone: { type: String, default: 'primary' }, // primary | success | warning | danger
|
|
15
|
+
showValue: { type: Boolean, default: false },
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const service = useMachine(progress.machine, {
|
|
19
|
+
id: useId(),
|
|
20
|
+
min: 0,
|
|
21
|
+
locale: 'ru-RU',
|
|
22
|
+
get value() {
|
|
23
|
+
return props.value
|
|
24
|
+
},
|
|
25
|
+
get max() {
|
|
26
|
+
return props.max
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const api = computed(() => progress.connect(service, normalizeProps))
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<div class="k-progress" v-bind="api.getRootProps()">
|
|
35
|
+
<div v-if="label || showValue" class="k-progress__head">
|
|
36
|
+
<span class="k-progress__label" v-bind="api.getLabelProps()">{{ label }}</span>
|
|
37
|
+
<span v-if="showValue" class="k-progress__value" v-bind="api.getValueTextProps()">{{ api.percentAsString }}</span>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="k-progress__track" v-bind="api.getTrackProps()">
|
|
40
|
+
<div class="k-progress__bar" :class="`k-progress__bar--${tone}`" v-bind="api.getRangeProps()" />
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</template>
|
|
44
|
+
|
|
45
|
+
<style scoped>
|
|
46
|
+
.k-progress__head { display: flex; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.3rem; font-size: 0.8125rem; }
|
|
47
|
+
.k-progress__label { color: var(--k-text-muted); }
|
|
48
|
+
.k-progress__value { font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
49
|
+
.k-progress__track { height: 0.5rem; background: var(--k-surface-muted); border-radius: 999px; overflow: hidden; }
|
|
50
|
+
.k-progress__bar { height: 100%; border-radius: 999px; transition: width 0.3s ease; }
|
|
51
|
+
.k-progress__bar[data-state="indeterminate"] {
|
|
52
|
+
width: 40%;
|
|
53
|
+
animation: k-progress-slide 1.2s ease-in-out infinite;
|
|
54
|
+
}
|
|
55
|
+
.k-progress__bar--primary { background: var(--k-primary); }
|
|
56
|
+
.k-progress__bar--success { background: var(--k-success); }
|
|
57
|
+
.k-progress__bar--warning { background: var(--k-warning); }
|
|
58
|
+
.k-progress__bar--danger { background: var(--k-danger); }
|
|
59
|
+
@keyframes k-progress-slide {
|
|
60
|
+
from { transform: translateX(-100%); }
|
|
61
|
+
to { transform: translateX(250%); }
|
|
62
|
+
}
|
|
63
|
+
</style>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KRadioGroup — группа радио-кнопок на @zag-js/radio-group: навигация
|
|
4
|
+
* стрелками и roving tabindex от машины. Опции — строки или
|
|
5
|
+
* { value, label, hint?, disabled? }. Направление — колонка или строка.
|
|
6
|
+
*/
|
|
7
|
+
import * as radio from '@zag-js/radio-group'
|
|
8
|
+
import { normalizeProps, useMachine } from '@zag-js/vue'
|
|
9
|
+
import { computed, useId } from 'vue'
|
|
10
|
+
|
|
11
|
+
const props = defineProps({
|
|
12
|
+
modelValue: { type: [String, Number, Boolean, null], default: null },
|
|
13
|
+
options: { type: Array, default: () => [] },
|
|
14
|
+
label: { type: String, default: '' },
|
|
15
|
+
name: { type: String, default: '' },
|
|
16
|
+
inline: { type: Boolean, default: false },
|
|
17
|
+
disabled: { type: Boolean, default: false },
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const emit = defineEmits(['update:modelValue'])
|
|
21
|
+
|
|
22
|
+
const items = computed(() =>
|
|
23
|
+
props.options.map((o) => (typeof o === 'object' && o !== null ? o : { value: o, label: String(o) })),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const service = useMachine(radio.machine, {
|
|
27
|
+
id: useId(),
|
|
28
|
+
get value() {
|
|
29
|
+
return props.modelValue == null ? null : String(props.modelValue)
|
|
30
|
+
},
|
|
31
|
+
get disabled() {
|
|
32
|
+
return props.disabled
|
|
33
|
+
},
|
|
34
|
+
get name() {
|
|
35
|
+
return props.name || undefined
|
|
36
|
+
},
|
|
37
|
+
get orientation() {
|
|
38
|
+
return props.inline ? 'horizontal' : 'vertical'
|
|
39
|
+
},
|
|
40
|
+
onValueChange({ value }) {
|
|
41
|
+
const item = items.value.find((i) => String(i.value) === value)
|
|
42
|
+
emit('update:modelValue', item ? item.value : value)
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const api = computed(() => radio.connect(service, normalizeProps))
|
|
47
|
+
|
|
48
|
+
const itemProps = (item) => ({ value: String(item.value), disabled: item.disabled })
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<template>
|
|
52
|
+
<div class="k-radio" v-bind="api.getRootProps()">
|
|
53
|
+
<span v-if="label" class="k-radio__legend" v-bind="api.getLabelProps()">{{ label }}</span>
|
|
54
|
+
<div class="k-radio__items" :class="{ 'k-radio__items--inline': inline }">
|
|
55
|
+
<label
|
|
56
|
+
v-for="item in items"
|
|
57
|
+
:key="item.value"
|
|
58
|
+
class="k-radio__item"
|
|
59
|
+
v-bind="api.getItemProps(itemProps(item))"
|
|
60
|
+
>
|
|
61
|
+
<input v-bind="api.getItemHiddenInputProps(itemProps(item))" />
|
|
62
|
+
<span class="k-radio__control" v-bind="api.getItemControlProps(itemProps(item))" />
|
|
63
|
+
<span class="k-radio__body">
|
|
64
|
+
<span class="k-radio__label" v-bind="api.getItemTextProps(itemProps(item))">{{ item.label }}</span>
|
|
65
|
+
<span v-if="item.hint" class="k-radio__hint">{{ item.hint }}</span>
|
|
66
|
+
</span>
|
|
67
|
+
</label>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
</template>
|
|
71
|
+
|
|
72
|
+
<style scoped>
|
|
73
|
+
.k-radio { min-width: 0; }
|
|
74
|
+
.k-radio__legend { display: block; padding-bottom: 0.375rem; font-size: 0.8125rem; color: var(--k-text-muted); }
|
|
75
|
+
.k-radio__items { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
76
|
+
.k-radio__items--inline { flex-direction: row; flex-wrap: wrap; gap: 1rem; }
|
|
77
|
+
.k-radio__item { display: inline-flex; align-items: flex-start; gap: 0.5rem; cursor: pointer; }
|
|
78
|
+
.k-radio__item[data-disabled] { cursor: not-allowed; opacity: 0.6; }
|
|
79
|
+
.k-radio__control {
|
|
80
|
+
position: relative;
|
|
81
|
+
width: 1.05rem;
|
|
82
|
+
height: 1.05rem;
|
|
83
|
+
margin-top: 0.15rem;
|
|
84
|
+
flex: none;
|
|
85
|
+
background: var(--k-surface);
|
|
86
|
+
border: 1px solid var(--k-border-strong);
|
|
87
|
+
border-radius: 50%;
|
|
88
|
+
transition: border-color 0.12s, box-shadow 0.12s;
|
|
89
|
+
}
|
|
90
|
+
.k-radio__control[data-hover]:not([data-disabled]) { border-color: var(--k-primary); }
|
|
91
|
+
.k-radio__control[data-focus-visible] { box-shadow: 0 0 0 3px var(--k-primary-soft); border-color: var(--k-primary); }
|
|
92
|
+
.k-radio__control[data-state="checked"] { border-color: var(--k-primary); }
|
|
93
|
+
.k-radio__control[data-state="checked"]::after {
|
|
94
|
+
content: '';
|
|
95
|
+
position: absolute;
|
|
96
|
+
inset: 3px;
|
|
97
|
+
border-radius: 50%;
|
|
98
|
+
background: var(--k-primary);
|
|
99
|
+
}
|
|
100
|
+
.k-radio__body { display: flex; flex-direction: column; gap: 0.1rem; }
|
|
101
|
+
.k-radio__label { font-size: 0.9375rem; }
|
|
102
|
+
.k-radio__hint { font-size: 0.75rem; color: var(--k-text-muted); }
|
|
103
|
+
</style>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KSegmented — компактный переключатель режимов (карточки/таблица, день/месяц)
|
|
4
|
+
* на @zag-js/radio-group: семантически это радио-группа, поэтому стрелки и
|
|
5
|
+
* фокус работают как у неё. В отличие от KTabs не претендует на навигацию.
|
|
6
|
+
*
|
|
7
|
+
* options: [{ value, label?, icon?, title? }]
|
|
8
|
+
*/
|
|
9
|
+
import * as radio from '@zag-js/radio-group'
|
|
10
|
+
import { normalizeProps, useMachine } from '@zag-js/vue'
|
|
11
|
+
import { computed, useId } from 'vue'
|
|
12
|
+
import KIcon from './KIcon.vue'
|
|
13
|
+
|
|
14
|
+
const props = defineProps({
|
|
15
|
+
modelValue: { type: [String, Number], default: '' },
|
|
16
|
+
options: { type: Array, default: () => [] },
|
|
17
|
+
size: { type: String, default: 'md' }, // sm | md
|
|
18
|
+
disabled: { type: Boolean, default: false },
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const emit = defineEmits(['update:modelValue'])
|
|
22
|
+
|
|
23
|
+
const service = useMachine(radio.machine, {
|
|
24
|
+
id: useId(),
|
|
25
|
+
orientation: 'horizontal',
|
|
26
|
+
get value() {
|
|
27
|
+
return props.modelValue === '' || props.modelValue == null ? null : String(props.modelValue)
|
|
28
|
+
},
|
|
29
|
+
get disabled() {
|
|
30
|
+
return props.disabled
|
|
31
|
+
},
|
|
32
|
+
onValueChange({ value }) {
|
|
33
|
+
const item = props.options.find((o) => String(o.value) === value)
|
|
34
|
+
emit('update:modelValue', item ? item.value : value)
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const api = computed(() => radio.connect(service, normalizeProps))
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<div class="k-segmented" :class="`k-segmented--${size}`" v-bind="api.getRootProps()">
|
|
43
|
+
<label
|
|
44
|
+
v-for="opt in options"
|
|
45
|
+
:key="opt.value"
|
|
46
|
+
class="k-segmented__item"
|
|
47
|
+
:title="opt.title"
|
|
48
|
+
v-bind="api.getItemProps({ value: String(opt.value) })"
|
|
49
|
+
>
|
|
50
|
+
<input v-bind="api.getItemHiddenInputProps({ value: String(opt.value) })" />
|
|
51
|
+
<KIcon v-if="opt.icon" :name="opt.icon" :size="size === 'sm' ? 14 : 16" />
|
|
52
|
+
<span v-if="opt.label" v-bind="api.getItemTextProps({ value: String(opt.value) })">{{ opt.label }}</span>
|
|
53
|
+
</label>
|
|
54
|
+
</div>
|
|
55
|
+
</template>
|
|
56
|
+
|
|
57
|
+
<style scoped>
|
|
58
|
+
.k-segmented {
|
|
59
|
+
display: inline-flex;
|
|
60
|
+
padding: 0.15rem;
|
|
61
|
+
background: var(--k-surface-muted);
|
|
62
|
+
border: 1px solid var(--k-border);
|
|
63
|
+
border-radius: var(--k-radius-sm);
|
|
64
|
+
gap: 0.15rem;
|
|
65
|
+
}
|
|
66
|
+
.k-segmented__item {
|
|
67
|
+
display: inline-flex;
|
|
68
|
+
align-items: center;
|
|
69
|
+
gap: 0.35rem;
|
|
70
|
+
padding: 0.3rem 0.65rem;
|
|
71
|
+
border-radius: calc(var(--k-radius-sm) - 2px);
|
|
72
|
+
font-size: 0.875rem;
|
|
73
|
+
color: var(--k-text-muted);
|
|
74
|
+
cursor: pointer;
|
|
75
|
+
white-space: nowrap;
|
|
76
|
+
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
|
|
77
|
+
}
|
|
78
|
+
.k-segmented--sm .k-segmented__item { padding: 0.2rem 0.5rem; font-size: 0.8125rem; }
|
|
79
|
+
.k-segmented__item[data-hover]:not([data-disabled]) { color: var(--k-text); }
|
|
80
|
+
.k-segmented__item[data-focus-visible] { box-shadow: 0 0 0 2px var(--k-primary); }
|
|
81
|
+
.k-segmented__item[data-disabled] { cursor: not-allowed; opacity: 0.5; }
|
|
82
|
+
.k-segmented__item[data-state="checked"] {
|
|
83
|
+
background: var(--k-surface);
|
|
84
|
+
color: var(--k-text);
|
|
85
|
+
font-weight: 550;
|
|
86
|
+
box-shadow: var(--k-shadow-sm);
|
|
87
|
+
}
|
|
88
|
+
</style>
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KSelect — выпадающий список с одиночным выбором на @zag-js/select.
|
|
4
|
+
*
|
|
5
|
+
* Опции — массив строк или любых объектов; ключ и подпись извлекаются через
|
|
6
|
+
* `getValue`/`getLabel`, поэтому в v-model живёт исходное значение (число,
|
|
7
|
+
* строка, id), а не его строковое представление. Список рендерится в <body>,
|
|
8
|
+
* чтобы не обрезаться внутри модалок и карточек с overflow.
|
|
9
|
+
*
|
|
10
|
+
* <KSelect v-model="role" label="Роль" :options="[{ value: 'admin', label: 'Администратор' }]" />
|
|
11
|
+
* <KSelect v-model="volumeId" :options="volumes" :get-value="v => v.id" :get-label="v => v.name" clearable />
|
|
12
|
+
*/
|
|
13
|
+
import * as select from '@zag-js/select'
|
|
14
|
+
import { normalizeProps, useMachine } from '@zag-js/vue'
|
|
15
|
+
import { computed, ref, useAttrs, useId } from 'vue'
|
|
16
|
+
import KIcon from './KIcon.vue'
|
|
17
|
+
|
|
18
|
+
defineOptions({ inheritAttrs: false })
|
|
19
|
+
|
|
20
|
+
const attrs = useAttrs()
|
|
21
|
+
const rootAttrs = computed(() => ({ class: attrs.class, style: attrs.style }))
|
|
22
|
+
|
|
23
|
+
const props = defineProps({
|
|
24
|
+
modelValue: { type: [String, Number, Boolean, null], default: null },
|
|
25
|
+
options: { type: Array, default: () => [] },
|
|
26
|
+
getValue: { type: Function, default: (o) => o.value },
|
|
27
|
+
getLabel: { type: Function, default: (o) => o.label },
|
|
28
|
+
label: { type: String, default: '' },
|
|
29
|
+
placeholder: { type: String, default: 'Выберите…' },
|
|
30
|
+
hint: { type: String, default: '' },
|
|
31
|
+
error: { type: String, default: '' },
|
|
32
|
+
name: { type: String, default: '' },
|
|
33
|
+
required: { type: Boolean, default: false },
|
|
34
|
+
disabled: { type: Boolean, default: false },
|
|
35
|
+
clearable: { type: Boolean, default: false },
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const emit = defineEmits(['update:modelValue'])
|
|
39
|
+
|
|
40
|
+
const box = ref(null)
|
|
41
|
+
|
|
42
|
+
const items = computed(() =>
|
|
43
|
+
props.options.map((o) => (typeof o === 'object' && o !== null ? o : { value: o, label: String(o) })),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const keyOf = (item) => String(props.getValue(item))
|
|
47
|
+
const labelOf = (item) => String(props.getLabel(item))
|
|
48
|
+
|
|
49
|
+
const collection = computed(() =>
|
|
50
|
+
select.collection({ items: items.value, itemToValue: keyOf, itemToString: labelOf }),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
function itemFor(raw) {
|
|
54
|
+
if (raw == null || raw === '') return undefined
|
|
55
|
+
return items.value.find((i) => keyOf(i) === String(raw))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const service = useMachine(select.machine, {
|
|
59
|
+
id: useId(),
|
|
60
|
+
positioning: { sameWidth: true, getAnchorElement: () => box.value },
|
|
61
|
+
get collection() {
|
|
62
|
+
return collection.value
|
|
63
|
+
},
|
|
64
|
+
get disabled() {
|
|
65
|
+
return props.disabled
|
|
66
|
+
},
|
|
67
|
+
get invalid() {
|
|
68
|
+
return !!props.error
|
|
69
|
+
},
|
|
70
|
+
get required() {
|
|
71
|
+
return props.required
|
|
72
|
+
},
|
|
73
|
+
get name() {
|
|
74
|
+
return props.name || undefined
|
|
75
|
+
},
|
|
76
|
+
get value() {
|
|
77
|
+
const match = itemFor(props.modelValue)
|
|
78
|
+
return match ? [keyOf(match)] : []
|
|
79
|
+
},
|
|
80
|
+
onValueChange({ value }) {
|
|
81
|
+
const item = itemFor(value[0])
|
|
82
|
+
emit('update:modelValue', item !== undefined ? props.getValue(item) : null)
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const api = computed(() => select.connect(service, normalizeProps))
|
|
87
|
+
</script>
|
|
88
|
+
|
|
89
|
+
<template>
|
|
90
|
+
<div class="k-select" v-bind="{ ...rootAttrs, ...api.getRootProps() }">
|
|
91
|
+
<label v-if="label" class="k-select__label" v-bind="api.getLabelProps()">
|
|
92
|
+
{{ label }}<span v-if="required" class="k-select__req">*</span>
|
|
93
|
+
</label>
|
|
94
|
+
<div
|
|
95
|
+
ref="box"
|
|
96
|
+
class="k-select__box"
|
|
97
|
+
:class="{ 'k-select__box--error': error, 'k-select__box--disabled': disabled }"
|
|
98
|
+
v-bind="api.getControlProps()"
|
|
99
|
+
>
|
|
100
|
+
<button class="k-select__trigger" v-bind="api.getTriggerProps()">
|
|
101
|
+
<span
|
|
102
|
+
class="k-select__value"
|
|
103
|
+
:class="{ 'k-select__value--placeholder': !api.hasSelectedItems }"
|
|
104
|
+
v-bind="api.getValueTextProps()"
|
|
105
|
+
>{{ api.hasSelectedItems ? api.valueAsString : placeholder }}</span>
|
|
106
|
+
</button>
|
|
107
|
+
<button
|
|
108
|
+
v-if="clearable && api.hasSelectedItems"
|
|
109
|
+
class="k-select__clear"
|
|
110
|
+
aria-label="Очистить"
|
|
111
|
+
v-bind="api.getClearTriggerProps()"
|
|
112
|
+
>
|
|
113
|
+
<KIcon name="close" :size="14" />
|
|
114
|
+
</button>
|
|
115
|
+
<span class="k-select__chevron" v-bind="api.getIndicatorProps()">
|
|
116
|
+
<KIcon name="chevron-down" :size="16" />
|
|
117
|
+
</span>
|
|
118
|
+
</div>
|
|
119
|
+
<select v-bind="api.getHiddenSelectProps()">
|
|
120
|
+
<option v-for="item in items" :key="keyOf(item)" :value="keyOf(item)">{{ labelOf(item) }}</option>
|
|
121
|
+
</select>
|
|
122
|
+
<span v-if="error" class="k-select__error">{{ error }}</span>
|
|
123
|
+
<span v-else-if="hint" class="k-select__hint">{{ hint }}</span>
|
|
124
|
+
|
|
125
|
+
<Teleport to="body">
|
|
126
|
+
<div v-bind="api.getPositionerProps()">
|
|
127
|
+
<ul class="k-select__content" v-bind="api.getContentProps()">
|
|
128
|
+
<li
|
|
129
|
+
v-for="item in items"
|
|
130
|
+
:key="keyOf(item)"
|
|
131
|
+
class="k-select__item"
|
|
132
|
+
v-bind="api.getItemProps({ item })"
|
|
133
|
+
>
|
|
134
|
+
<span class="k-select__item-text" v-bind="api.getItemTextProps({ item })">{{ labelOf(item) }}</span>
|
|
135
|
+
<span class="k-select__item-check" v-bind="api.getItemIndicatorProps({ item })">
|
|
136
|
+
<KIcon name="check" :size="15" />
|
|
137
|
+
</span>
|
|
138
|
+
</li>
|
|
139
|
+
<li v-if="!items.length" class="k-select__empty">Нет вариантов</li>
|
|
140
|
+
</ul>
|
|
141
|
+
</div>
|
|
142
|
+
</Teleport>
|
|
143
|
+
</div>
|
|
144
|
+
</template>
|
|
145
|
+
|
|
146
|
+
<style scoped>
|
|
147
|
+
.k-select { display: flex; flex-direction: column; gap: 0.25rem; margin: 0; min-width: 0; }
|
|
148
|
+
.k-select__label { font-size: 0.8125rem; color: var(--k-text-muted); }
|
|
149
|
+
.k-select__req { color: var(--k-danger); margin-left: 2px; }
|
|
150
|
+
.k-select__box {
|
|
151
|
+
display: flex;
|
|
152
|
+
align-items: center;
|
|
153
|
+
height: var(--k-control-h);
|
|
154
|
+
padding-right: 0.5rem;
|
|
155
|
+
background: var(--k-surface);
|
|
156
|
+
border: 1px solid var(--k-border-strong);
|
|
157
|
+
border-radius: var(--k-radius-sm);
|
|
158
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
159
|
+
}
|
|
160
|
+
.k-select__box:focus-within { border-color: var(--k-primary); box-shadow: 0 0 0 3px var(--k-primary-soft); }
|
|
161
|
+
.k-select__box--error { border-color: var(--k-danger); }
|
|
162
|
+
.k-select__box--error:focus-within { box-shadow: 0 0 0 3px var(--k-danger-soft); }
|
|
163
|
+
.k-select__box--disabled { background: var(--k-surface-muted); }
|
|
164
|
+
.k-select__trigger {
|
|
165
|
+
flex: 1;
|
|
166
|
+
min-width: 0;
|
|
167
|
+
height: 100%;
|
|
168
|
+
padding: 0 0.625rem;
|
|
169
|
+
border: none;
|
|
170
|
+
background: none;
|
|
171
|
+
outline: none;
|
|
172
|
+
font: inherit;
|
|
173
|
+
font-size: 0.9375rem;
|
|
174
|
+
color: inherit;
|
|
175
|
+
text-align: left;
|
|
176
|
+
cursor: pointer;
|
|
177
|
+
}
|
|
178
|
+
.k-select__trigger:disabled { cursor: not-allowed; color: var(--k-text-muted); }
|
|
179
|
+
.k-select__value { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
180
|
+
.k-select__value--placeholder { color: var(--k-text-faint); }
|
|
181
|
+
.k-select__clear {
|
|
182
|
+
display: inline-flex;
|
|
183
|
+
padding: 0.15rem;
|
|
184
|
+
border: none;
|
|
185
|
+
border-radius: var(--k-radius-sm);
|
|
186
|
+
background: none;
|
|
187
|
+
color: var(--k-text-faint);
|
|
188
|
+
cursor: pointer;
|
|
189
|
+
}
|
|
190
|
+
.k-select__clear:hover { color: var(--k-text); background: var(--k-surface-muted); }
|
|
191
|
+
.k-select__chevron { display: inline-flex; color: var(--k-text-faint); pointer-events: none; transition: transform 0.15s; }
|
|
192
|
+
.k-select__chevron[data-state="open"] { transform: rotate(180deg); }
|
|
193
|
+
.k-select__hint { font-size: 0.75rem; color: var(--k-text-muted); }
|
|
194
|
+
.k-select__error { font-size: 0.75rem; color: var(--k-danger); }
|
|
195
|
+
|
|
196
|
+
/* zag читает вычисленный z-index контента и переносит его на positioner —
|
|
197
|
+
задаём здесь, выше модалки (1000), чтобы список не прятался под ней. */
|
|
198
|
+
.k-select__content {
|
|
199
|
+
z-index: 1100;
|
|
200
|
+
list-style: none;
|
|
201
|
+
margin: 0;
|
|
202
|
+
padding: 0.25rem;
|
|
203
|
+
max-height: 260px;
|
|
204
|
+
overflow-y: auto;
|
|
205
|
+
min-width: var(--reference-width);
|
|
206
|
+
background: var(--k-surface);
|
|
207
|
+
border: 1px solid var(--k-border);
|
|
208
|
+
border-radius: var(--k-radius);
|
|
209
|
+
box-shadow: var(--k-shadow-md);
|
|
210
|
+
outline: none;
|
|
211
|
+
}
|
|
212
|
+
.k-select__content[hidden] { display: none; }
|
|
213
|
+
.k-select__item {
|
|
214
|
+
display: flex;
|
|
215
|
+
align-items: center;
|
|
216
|
+
justify-content: space-between;
|
|
217
|
+
gap: 0.5rem;
|
|
218
|
+
padding: 0.45rem 0.6rem;
|
|
219
|
+
border-radius: var(--k-radius-sm);
|
|
220
|
+
font-size: 0.9rem;
|
|
221
|
+
color: var(--k-text);
|
|
222
|
+
cursor: pointer;
|
|
223
|
+
}
|
|
224
|
+
.k-select__item[data-highlighted] { background: var(--k-primary-soft); color: var(--k-primary); }
|
|
225
|
+
.k-select__item[data-state="checked"] { font-weight: 600; }
|
|
226
|
+
.k-select__item[data-disabled] { opacity: 0.5; cursor: not-allowed; }
|
|
227
|
+
.k-select__item-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
228
|
+
.k-select__item-check { display: inline-flex; flex: none; }
|
|
229
|
+
.k-select__item-check[hidden] { display: none; }
|
|
230
|
+
.k-select__empty { padding: 0.5rem 0.6rem; font-size: 0.875rem; color: var(--k-text-muted); }
|
|
231
|
+
</style>
|