@krometra/ui-kit 0.0.0-stage → 0.4.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 +235 -2
- package/dist/ui-kit.css +2 -0
- package/dist/ui-kit.js +15222 -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/KGantt.vue +398 -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 +85 -0
- package/src/theme.js +417 -0
- package/src/tokens.css +109 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KCombobox — поле с поиском по списку на @zag-js/combobox.
|
|
4
|
+
*
|
|
5
|
+
* Для длинных справочников (контрагенты, пользователи), где нативный select
|
|
6
|
+
* неудобен. Фильтрует по подписи локально; для серверного поиска слушайте
|
|
7
|
+
* событие `search` и подменяйте `options`.
|
|
8
|
+
*
|
|
9
|
+
* <KCombobox v-model="form.counterpartyId" :options="counterparties"
|
|
10
|
+
* :get-value="c => c.id" :get-label="c => c.name" label="Контрагент" />
|
|
11
|
+
*/
|
|
12
|
+
import * as combobox from '@zag-js/combobox'
|
|
13
|
+
import { normalizeProps, useMachine } from '@zag-js/vue'
|
|
14
|
+
import { computed, ref, useAttrs, useId, watch } from 'vue'
|
|
15
|
+
import KIcon from './KIcon.vue'
|
|
16
|
+
|
|
17
|
+
defineOptions({ inheritAttrs: false })
|
|
18
|
+
|
|
19
|
+
const attrs = useAttrs()
|
|
20
|
+
const rootAttrs = computed(() => ({ class: attrs.class, style: attrs.style }))
|
|
21
|
+
|
|
22
|
+
const props = defineProps({
|
|
23
|
+
modelValue: { type: [String, Number, null], default: null },
|
|
24
|
+
options: { type: Array, default: () => [] },
|
|
25
|
+
getValue: { type: Function, default: (o) => o.value ?? o.id },
|
|
26
|
+
getLabel: { type: Function, default: (o) => o.label ?? o.name ?? '' },
|
|
27
|
+
label: { type: String, default: '' },
|
|
28
|
+
placeholder: { type: String, default: 'Начните вводить…' },
|
|
29
|
+
hint: { type: String, default: '' },
|
|
30
|
+
error: { type: String, default: '' },
|
|
31
|
+
emptyText: { 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: true },
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const emit = defineEmits(['update:modelValue', 'search'])
|
|
39
|
+
|
|
40
|
+
const items = computed(() =>
|
|
41
|
+
props.options.map((o) => (typeof o === 'object' && o !== null ? o : { value: o, label: String(o) })),
|
|
42
|
+
)
|
|
43
|
+
const keyOf = (item) => String(props.getValue(item))
|
|
44
|
+
const labelOf = (item) => String(props.getLabel(item))
|
|
45
|
+
|
|
46
|
+
const filtered = ref([])
|
|
47
|
+
watch(items, (next) => { filtered.value = next }, { immediate: true })
|
|
48
|
+
|
|
49
|
+
function applyFilter(text) {
|
|
50
|
+
const q = (text || '').toLowerCase().trim()
|
|
51
|
+
filtered.value = q ? items.value.filter((i) => labelOf(i).toLowerCase().includes(q)) : items.value
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const collection = computed(() =>
|
|
55
|
+
combobox.collection({ items: filtered.value, itemToValue: keyOf, itemToString: labelOf }),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
function itemFor(raw) {
|
|
59
|
+
if (raw == null || raw === '') return undefined
|
|
60
|
+
return items.value.find((i) => keyOf(i) === String(raw))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const service = useMachine(combobox.machine, {
|
|
64
|
+
id: useId(),
|
|
65
|
+
positioning: { sameWidth: true },
|
|
66
|
+
get collection() {
|
|
67
|
+
return collection.value
|
|
68
|
+
},
|
|
69
|
+
get disabled() {
|
|
70
|
+
return props.disabled
|
|
71
|
+
},
|
|
72
|
+
get invalid() {
|
|
73
|
+
return !!props.error
|
|
74
|
+
},
|
|
75
|
+
get required() {
|
|
76
|
+
return props.required
|
|
77
|
+
},
|
|
78
|
+
get name() {
|
|
79
|
+
return props.name || undefined
|
|
80
|
+
},
|
|
81
|
+
get placeholder() {
|
|
82
|
+
return props.placeholder
|
|
83
|
+
},
|
|
84
|
+
get value() {
|
|
85
|
+
const match = itemFor(props.modelValue)
|
|
86
|
+
return match ? [keyOf(match)] : []
|
|
87
|
+
},
|
|
88
|
+
onOpenChange() {
|
|
89
|
+
filtered.value = items.value
|
|
90
|
+
},
|
|
91
|
+
onInputValueChange({ inputValue }) {
|
|
92
|
+
applyFilter(inputValue)
|
|
93
|
+
emit('search', inputValue)
|
|
94
|
+
},
|
|
95
|
+
onValueChange({ value }) {
|
|
96
|
+
const item = itemFor(value[0])
|
|
97
|
+
emit('update:modelValue', item !== undefined ? props.getValue(item) : null)
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const api = computed(() => combobox.connect(service, normalizeProps))
|
|
102
|
+
</script>
|
|
103
|
+
|
|
104
|
+
<template>
|
|
105
|
+
<div class="k-combobox" v-bind="{ ...rootAttrs, ...api.getRootProps() }">
|
|
106
|
+
<label v-if="label" class="k-combobox__label" v-bind="api.getLabelProps()">
|
|
107
|
+
{{ label }}<span v-if="required" class="k-combobox__req">*</span>
|
|
108
|
+
</label>
|
|
109
|
+
<div
|
|
110
|
+
class="k-combobox__box"
|
|
111
|
+
:class="{ 'k-combobox__box--error': error, 'k-combobox__box--disabled': disabled }"
|
|
112
|
+
v-bind="api.getControlProps()"
|
|
113
|
+
>
|
|
114
|
+
<input class="k-combobox__input" v-bind="api.getInputProps()" />
|
|
115
|
+
<button
|
|
116
|
+
v-if="clearable && api.hasSelectedItems"
|
|
117
|
+
class="k-combobox__clear"
|
|
118
|
+
aria-label="Очистить"
|
|
119
|
+
v-bind="api.getClearTriggerProps()"
|
|
120
|
+
>
|
|
121
|
+
<KIcon name="close" :size="14" />
|
|
122
|
+
</button>
|
|
123
|
+
<button class="k-combobox__trigger" aria-label="Показать список" v-bind="api.getTriggerProps()">
|
|
124
|
+
<KIcon name="chevron-down" :size="16" />
|
|
125
|
+
</button>
|
|
126
|
+
</div>
|
|
127
|
+
<span v-if="error" class="k-combobox__error">{{ error }}</span>
|
|
128
|
+
<span v-else-if="hint" class="k-combobox__hint">{{ hint }}</span>
|
|
129
|
+
|
|
130
|
+
<Teleport to="body">
|
|
131
|
+
<div v-bind="api.getPositionerProps()">
|
|
132
|
+
<ul class="k-combobox__content" v-bind="api.getContentProps()">
|
|
133
|
+
<li
|
|
134
|
+
v-for="item in filtered"
|
|
135
|
+
:key="keyOf(item)"
|
|
136
|
+
class="k-combobox__item"
|
|
137
|
+
v-bind="api.getItemProps({ item })"
|
|
138
|
+
>
|
|
139
|
+
<span class="k-combobox__item-text" v-bind="api.getItemTextProps({ item })">{{ labelOf(item) }}</span>
|
|
140
|
+
<span class="k-combobox__item-check" v-bind="api.getItemIndicatorProps({ item })">
|
|
141
|
+
<KIcon name="check" :size="15" />
|
|
142
|
+
</span>
|
|
143
|
+
</li>
|
|
144
|
+
<li v-if="!filtered.length" class="k-combobox__empty">{{ emptyText }}</li>
|
|
145
|
+
</ul>
|
|
146
|
+
</div>
|
|
147
|
+
</Teleport>
|
|
148
|
+
</div>
|
|
149
|
+
</template>
|
|
150
|
+
|
|
151
|
+
<style scoped>
|
|
152
|
+
.k-combobox { display: flex; flex-direction: column; gap: 0.25rem; margin: 0; min-width: 0; }
|
|
153
|
+
.k-combobox__label { font-size: 0.8125rem; color: var(--k-text-muted); }
|
|
154
|
+
.k-combobox__req { color: var(--k-danger); margin-left: 2px; }
|
|
155
|
+
.k-combobox__box {
|
|
156
|
+
display: flex;
|
|
157
|
+
align-items: center;
|
|
158
|
+
height: var(--k-control-h);
|
|
159
|
+
padding-right: 0.25rem;
|
|
160
|
+
background: var(--k-surface);
|
|
161
|
+
border: 1px solid var(--k-border-strong);
|
|
162
|
+
border-radius: var(--k-radius-sm);
|
|
163
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
164
|
+
}
|
|
165
|
+
.k-combobox__box:focus-within { border-color: var(--k-primary); box-shadow: 0 0 0 3px var(--k-primary-soft); }
|
|
166
|
+
.k-combobox__box--error { border-color: var(--k-danger); }
|
|
167
|
+
.k-combobox__box--error:focus-within { box-shadow: 0 0 0 3px var(--k-danger-soft); }
|
|
168
|
+
.k-combobox__box--disabled { background: var(--k-surface-muted); }
|
|
169
|
+
.k-combobox__input {
|
|
170
|
+
flex: 1;
|
|
171
|
+
min-width: 0;
|
|
172
|
+
height: 100%;
|
|
173
|
+
padding: 0 0.625rem;
|
|
174
|
+
border: none;
|
|
175
|
+
background: none;
|
|
176
|
+
outline: none;
|
|
177
|
+
font: inherit;
|
|
178
|
+
font-size: 0.9375rem;
|
|
179
|
+
color: inherit;
|
|
180
|
+
}
|
|
181
|
+
.k-combobox__input::placeholder { color: var(--k-text-faint); }
|
|
182
|
+
.k-combobox__input:disabled { cursor: not-allowed; color: var(--k-text-muted); }
|
|
183
|
+
.k-combobox__clear,
|
|
184
|
+
.k-combobox__trigger {
|
|
185
|
+
display: inline-flex;
|
|
186
|
+
align-items: center;
|
|
187
|
+
justify-content: center;
|
|
188
|
+
padding: 0.2rem;
|
|
189
|
+
border: none;
|
|
190
|
+
border-radius: var(--k-radius-sm);
|
|
191
|
+
background: none;
|
|
192
|
+
color: var(--k-text-faint);
|
|
193
|
+
cursor: pointer;
|
|
194
|
+
}
|
|
195
|
+
.k-combobox__clear:hover,
|
|
196
|
+
.k-combobox__trigger:hover:not(:disabled) { color: var(--k-text); background: var(--k-surface-muted); }
|
|
197
|
+
.k-combobox__trigger:disabled { cursor: not-allowed; }
|
|
198
|
+
.k-combobox__hint { font-size: 0.75rem; color: var(--k-text-muted); }
|
|
199
|
+
.k-combobox__error { font-size: 0.75rem; color: var(--k-danger); }
|
|
200
|
+
|
|
201
|
+
.k-combobox__content {
|
|
202
|
+
z-index: 1100;
|
|
203
|
+
list-style: none;
|
|
204
|
+
margin: 0;
|
|
205
|
+
padding: 0.25rem;
|
|
206
|
+
max-height: 260px;
|
|
207
|
+
overflow-y: auto;
|
|
208
|
+
min-width: var(--reference-width);
|
|
209
|
+
background: var(--k-surface);
|
|
210
|
+
border: 1px solid var(--k-border);
|
|
211
|
+
border-radius: var(--k-radius);
|
|
212
|
+
box-shadow: var(--k-shadow-md);
|
|
213
|
+
outline: none;
|
|
214
|
+
}
|
|
215
|
+
.k-combobox__content[hidden] { display: none; }
|
|
216
|
+
.k-combobox__item {
|
|
217
|
+
display: flex;
|
|
218
|
+
align-items: center;
|
|
219
|
+
justify-content: space-between;
|
|
220
|
+
gap: 0.5rem;
|
|
221
|
+
padding: 0.45rem 0.6rem;
|
|
222
|
+
border-radius: var(--k-radius-sm);
|
|
223
|
+
font-size: 0.9rem;
|
|
224
|
+
cursor: pointer;
|
|
225
|
+
}
|
|
226
|
+
.k-combobox__item[data-highlighted] { background: var(--k-primary-soft); color: var(--k-primary); }
|
|
227
|
+
.k-combobox__item[data-state="checked"] { font-weight: 600; }
|
|
228
|
+
.k-combobox__item-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
229
|
+
.k-combobox__item-check { display: inline-flex; flex: none; }
|
|
230
|
+
.k-combobox__item-check[hidden] { display: none; }
|
|
231
|
+
.k-combobox__empty { padding: 0.5rem 0.6rem; font-size: 0.875rem; color: var(--k-text-muted); }
|
|
232
|
+
</style>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KCountdown — обратный отсчёт до момента времени: дедлайн, окончание сессии,
|
|
4
|
+
* старт события.
|
|
5
|
+
*
|
|
6
|
+
* Тикает раз в секунду от переданного времени окончания. За `warnAt` секунд до
|
|
7
|
+
* нуля подсвечивается жёлтым, за `dangerAt` — красным, чтобы приближение
|
|
8
|
+
* дедлайна было заметно без чтения цифр.
|
|
9
|
+
*/
|
|
10
|
+
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
11
|
+
import KIcon from './KIcon.vue'
|
|
12
|
+
|
|
13
|
+
const props = defineProps({
|
|
14
|
+
to: { type: [String, Number, Date], required: true },
|
|
15
|
+
warnAt: { type: Number, default: 300 },
|
|
16
|
+
dangerAt: { type: Number, default: 60 },
|
|
17
|
+
showIcon: { type: Boolean, default: true },
|
|
18
|
+
size: { type: String, default: 'md' }, // sm | md | lg
|
|
19
|
+
endedText: { type: String, default: 'Завершено' },
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const now = ref(Date.now())
|
|
23
|
+
let timer = null
|
|
24
|
+
|
|
25
|
+
onMounted(() => { timer = setInterval(() => { now.value = Date.now() }, 1000) })
|
|
26
|
+
onBeforeUnmount(() => clearInterval(timer))
|
|
27
|
+
|
|
28
|
+
const left = computed(() => Math.max(0, Math.floor((new Date(props.to).getTime() - now.value) / 1000)))
|
|
29
|
+
const ended = computed(() => left.value <= 0)
|
|
30
|
+
|
|
31
|
+
const tone = computed(() => {
|
|
32
|
+
if (ended.value) return 'ended'
|
|
33
|
+
if (left.value <= props.dangerAt) return 'danger'
|
|
34
|
+
if (left.value <= props.warnAt) return 'warning'
|
|
35
|
+
return 'normal'
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const text = computed(() => {
|
|
39
|
+
if (ended.value) return props.endedText
|
|
40
|
+
const s = left.value
|
|
41
|
+
const d = Math.floor(s / 86400)
|
|
42
|
+
const h = Math.floor((s % 86400) / 3600)
|
|
43
|
+
const m = Math.floor((s % 3600) / 60)
|
|
44
|
+
const sec = s % 60
|
|
45
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
46
|
+
if (d > 0) return `${d} д ${pad(h)}:${pad(m)}:${pad(sec)}`
|
|
47
|
+
return `${pad(h)}:${pad(m)}:${pad(sec)}`
|
|
48
|
+
})
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<template>
|
|
52
|
+
<span class="k-countdown" :class="[`k-countdown--${tone}`, `k-countdown--${size}`]">
|
|
53
|
+
<KIcon v-if="showIcon" name="clock" :size="size === 'lg' ? 18 : 14" />
|
|
54
|
+
{{ text }}
|
|
55
|
+
</span>
|
|
56
|
+
</template>
|
|
57
|
+
|
|
58
|
+
<style scoped>
|
|
59
|
+
.k-countdown {
|
|
60
|
+
display: inline-flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
gap: 0.35rem;
|
|
63
|
+
font-variant-numeric: tabular-nums;
|
|
64
|
+
font-weight: 600;
|
|
65
|
+
white-space: nowrap;
|
|
66
|
+
}
|
|
67
|
+
.k-countdown--sm { font-size: 0.8125rem; }
|
|
68
|
+
.k-countdown--md { font-size: 0.9375rem; }
|
|
69
|
+
.k-countdown--lg { font-size: 1.5rem; letter-spacing: -0.01em; }
|
|
70
|
+
.k-countdown--normal { color: var(--k-text); }
|
|
71
|
+
.k-countdown--warning { color: var(--k-warning-strong); }
|
|
72
|
+
.k-countdown--danger { color: var(--k-danger); }
|
|
73
|
+
.k-countdown--ended { color: var(--k-text-faint); font-weight: 500; }
|
|
74
|
+
</style>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KDescriptions — список «поле → значение» для карточек сущностей.
|
|
4
|
+
* Значение можно переопределить слотом `#value-<key>`.
|
|
5
|
+
*
|
|
6
|
+
* items: [{ key, label, value? }]
|
|
7
|
+
*/
|
|
8
|
+
defineProps({
|
|
9
|
+
items: { type: Array, default: () => [] },
|
|
10
|
+
columns: { type: Number, default: 2 },
|
|
11
|
+
})
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<dl class="k-desc" :style="{ '--k-desc-cols': columns }">
|
|
16
|
+
<div v-for="item in items" :key="item.key" class="k-desc__row" :class="{ 'k-desc__row--wide': item.wide }">
|
|
17
|
+
<dt class="k-desc__label">{{ item.label }}</dt>
|
|
18
|
+
<dd class="k-desc__value">
|
|
19
|
+
<slot :name="`value-${item.key}`" :item="item">{{ item.value ?? '—' }}</slot>
|
|
20
|
+
</dd>
|
|
21
|
+
</div>
|
|
22
|
+
</dl>
|
|
23
|
+
</template>
|
|
24
|
+
|
|
25
|
+
<style scoped>
|
|
26
|
+
.k-desc {
|
|
27
|
+
display: grid;
|
|
28
|
+
grid-template-columns: repeat(var(--k-desc-cols, 2), minmax(0, 1fr));
|
|
29
|
+
gap: 0.875rem 1.5rem;
|
|
30
|
+
margin: 0;
|
|
31
|
+
}
|
|
32
|
+
.k-desc__row { min-width: 0; }
|
|
33
|
+
.k-desc__row--wide { grid-column: 1 / -1; }
|
|
34
|
+
.k-desc__label { font-size: 0.75rem; color: var(--k-text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
|
|
35
|
+
.k-desc__value { margin: 0.2rem 0 0; font-size: 0.9375rem; overflow-wrap: anywhere; }
|
|
36
|
+
|
|
37
|
+
@media (max-width: 720px) {
|
|
38
|
+
.k-desc { grid-template-columns: 1fr; }
|
|
39
|
+
}
|
|
40
|
+
</style>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KDropdown — кнопка, раскрывающая плавающую панель, на @zag-js/popover.
|
|
4
|
+
*
|
|
5
|
+
* Позиционирование, закрытие по клику вне и Esc, возврат фокуса — от машины.
|
|
6
|
+
* Панель рендерится в <body>, поэтому не обрезается таблицами и модалками.
|
|
7
|
+
*
|
|
8
|
+
* Триггер — либо собственная кнопка кита (пропы label/icon/variant/size), либо
|
|
9
|
+
* слот #trigger, который обязан привязать `props` к своему элементу:
|
|
10
|
+
*
|
|
11
|
+
* <KDropdown label="Экспорт" v-slot="{ close }">
|
|
12
|
+
* <KMenuItem icon="download">PDF</KMenuItem>
|
|
13
|
+
* </KDropdown>
|
|
14
|
+
*
|
|
15
|
+
* <KDropdown #trigger="{ props }">
|
|
16
|
+
* <KButton v-bind="props" variant="ghost" icon="more" />
|
|
17
|
+
* </KDropdown>
|
|
18
|
+
*
|
|
19
|
+
* Клик по KMenuItem закрывает панель (`close-on-select`); для панелей с
|
|
20
|
+
* формой внутри используйте `close` из слота.
|
|
21
|
+
*/
|
|
22
|
+
import * as popover from '@zag-js/popover'
|
|
23
|
+
import { normalizeProps, useMachine } from '@zag-js/vue'
|
|
24
|
+
import { computed, useId } from 'vue'
|
|
25
|
+
import KButton from './KButton.vue'
|
|
26
|
+
|
|
27
|
+
const props = defineProps({
|
|
28
|
+
modelValue: { type: Boolean, default: undefined },
|
|
29
|
+
label: { type: String, default: '' },
|
|
30
|
+
icon: { type: String, default: '' },
|
|
31
|
+
iconEnd: { type: String, default: undefined },
|
|
32
|
+
variant: { type: String, default: 'secondary' },
|
|
33
|
+
size: { type: String, default: 'md' },
|
|
34
|
+
align: { type: String, default: 'end' }, // start | end
|
|
35
|
+
placement: { type: String, default: '' },
|
|
36
|
+
width: { type: String, default: '13rem' },
|
|
37
|
+
closeOnSelect: { type: Boolean, default: true },
|
|
38
|
+
disabled: { type: Boolean, default: false },
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const emit = defineEmits(['update:modelValue'])
|
|
42
|
+
|
|
43
|
+
const triggerIcon = computed(() => (props.label ? props.icon : props.icon || 'more'))
|
|
44
|
+
const triggerIconEnd = computed(() => (props.label ? props.iconEnd ?? 'chevron-down' : ''))
|
|
45
|
+
|
|
46
|
+
const service = useMachine(popover.machine, {
|
|
47
|
+
id: useId(),
|
|
48
|
+
get positioning() {
|
|
49
|
+
return { placement: props.placement || (props.align === 'start' ? 'bottom-start' : 'bottom-end') }
|
|
50
|
+
},
|
|
51
|
+
get open() {
|
|
52
|
+
return props.modelValue
|
|
53
|
+
},
|
|
54
|
+
onOpenChange(details) {
|
|
55
|
+
emit('update:modelValue', details.open)
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const api = computed(() => popover.connect(service, normalizeProps))
|
|
60
|
+
|
|
61
|
+
function close() {
|
|
62
|
+
api.value.setOpen(false)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function onContentClick(event) {
|
|
66
|
+
if (props.closeOnSelect && event.target.closest('[data-k-menu-item]')) close()
|
|
67
|
+
}
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<template>
|
|
71
|
+
<span class="k-dropdown">
|
|
72
|
+
<slot name="trigger" :props="api.getTriggerProps()" :open="api.open">
|
|
73
|
+
<KButton
|
|
74
|
+
v-bind="api.getTriggerProps()"
|
|
75
|
+
:variant="variant"
|
|
76
|
+
:size="size"
|
|
77
|
+
:icon="triggerIcon"
|
|
78
|
+
:icon-end="triggerIconEnd"
|
|
79
|
+
:disabled="disabled"
|
|
80
|
+
>
|
|
81
|
+
<template v-if="label" #default>{{ label }}</template>
|
|
82
|
+
</KButton>
|
|
83
|
+
</slot>
|
|
84
|
+
<Teleport to="body">
|
|
85
|
+
<div v-bind="api.getPositionerProps()">
|
|
86
|
+
<div
|
|
87
|
+
class="k-dropdown__menu"
|
|
88
|
+
:style="{ minWidth: width }"
|
|
89
|
+
v-bind="api.getContentProps()"
|
|
90
|
+
@click="onContentClick"
|
|
91
|
+
>
|
|
92
|
+
<slot :close="close" />
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</Teleport>
|
|
96
|
+
</span>
|
|
97
|
+
</template>
|
|
98
|
+
|
|
99
|
+
<style scoped>
|
|
100
|
+
.k-dropdown { display: inline-flex; }
|
|
101
|
+
.k-dropdown__menu {
|
|
102
|
+
z-index: 1100;
|
|
103
|
+
padding: 0.3rem;
|
|
104
|
+
background: var(--k-surface);
|
|
105
|
+
border: 1px solid var(--k-border);
|
|
106
|
+
border-radius: var(--k-radius);
|
|
107
|
+
box-shadow: var(--k-shadow-md);
|
|
108
|
+
outline: none;
|
|
109
|
+
}
|
|
110
|
+
.k-dropdown__menu[hidden] { display: none; }
|
|
111
|
+
</style>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KEmpty — пустое состояние списка: иконка, заголовок, пояснение и действие.
|
|
4
|
+
*/
|
|
5
|
+
import KIcon from './KIcon.vue'
|
|
6
|
+
|
|
7
|
+
defineProps({
|
|
8
|
+
icon: { type: String, default: 'search' },
|
|
9
|
+
title: { type: String, default: 'Ничего не найдено' },
|
|
10
|
+
description: { type: String, default: '' },
|
|
11
|
+
})
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<div class="k-empty">
|
|
16
|
+
<span class="k-empty__icon"><KIcon :name="icon" :size="24" /></span>
|
|
17
|
+
<p class="k-empty__title">{{ title }}</p>
|
|
18
|
+
<p v-if="description" class="k-empty__desc">{{ description }}</p>
|
|
19
|
+
<div v-if="$slots.default" class="k-empty__actions"><slot /></div>
|
|
20
|
+
</div>
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<style scoped>
|
|
24
|
+
.k-empty { padding: 2.5rem 1.5rem; text-align: center; }
|
|
25
|
+
.k-empty__icon {
|
|
26
|
+
display: inline-flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
justify-content: center;
|
|
29
|
+
width: 3rem;
|
|
30
|
+
height: 3rem;
|
|
31
|
+
border-radius: 50%;
|
|
32
|
+
background: var(--k-surface-muted);
|
|
33
|
+
color: var(--k-text-faint);
|
|
34
|
+
margin-bottom: 0.75rem;
|
|
35
|
+
}
|
|
36
|
+
.k-empty__title { font-weight: 600; }
|
|
37
|
+
.k-empty__desc { margin-top: 0.25rem; font-size: 0.875rem; color: var(--k-text-muted); }
|
|
38
|
+
.k-empty__actions { margin-top: 1rem; display: flex; justify-content: center; gap: 0.5rem; }
|
|
39
|
+
</style>
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KFileUpload — зона загрузки файлов на @zag-js/file-upload: drag-and-drop,
|
|
4
|
+
* выбор по клику, проверка типа и размера, список выбранного с удалением.
|
|
5
|
+
*
|
|
6
|
+
* В v-model живут объекты File; отправка — забота приложения, кит не
|
|
7
|
+
* навязывает транспорт. Отклонённые файлы приходят событием `reject`.
|
|
8
|
+
*
|
|
9
|
+
* <KFileUpload v-model="files" accept=".pdf,application/pdf" :max-file-size="50 * 1024 * 1024" />
|
|
10
|
+
*/
|
|
11
|
+
import * as fileUpload from '@zag-js/file-upload'
|
|
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
|
+
modelValue: { type: Array, default: () => [] },
|
|
18
|
+
label: { type: String, default: 'Перетащите файлы или нажмите для выбора' },
|
|
19
|
+
hint: { type: String, default: '' },
|
|
20
|
+
accept: { type: String, default: '' },
|
|
21
|
+
multiple: { type: Boolean, default: true },
|
|
22
|
+
maxFileSize: { type: Number, default: Infinity },
|
|
23
|
+
disabled: { type: Boolean, default: false },
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const emit = defineEmits(['update:modelValue', 'reject'])
|
|
27
|
+
|
|
28
|
+
const service = useMachine(fileUpload.machine, {
|
|
29
|
+
id: useId(),
|
|
30
|
+
locale: 'ru-RU',
|
|
31
|
+
get accept() {
|
|
32
|
+
return props.accept ? props.accept.split(',').map((s) => s.trim()).filter(Boolean) : undefined
|
|
33
|
+
},
|
|
34
|
+
get maxFiles() {
|
|
35
|
+
return props.multiple ? Infinity : 1
|
|
36
|
+
},
|
|
37
|
+
get maxFileSize() {
|
|
38
|
+
return props.maxFileSize
|
|
39
|
+
},
|
|
40
|
+
get disabled() {
|
|
41
|
+
return props.disabled
|
|
42
|
+
},
|
|
43
|
+
get acceptedFiles() {
|
|
44
|
+
return props.modelValue
|
|
45
|
+
},
|
|
46
|
+
onFileChange({ acceptedFiles, rejectedFiles }) {
|
|
47
|
+
emit('update:modelValue', acceptedFiles)
|
|
48
|
+
if (rejectedFiles.length) emit('reject', rejectedFiles)
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const api = computed(() => fileUpload.connect(service, normalizeProps))
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<template>
|
|
56
|
+
<div class="k-upload" v-bind="api.getRootProps()">
|
|
57
|
+
<div class="k-upload__zone" v-bind="api.getDropzoneProps()">
|
|
58
|
+
<KIcon name="upload" :size="20" class="k-upload__icon" />
|
|
59
|
+
<span class="k-upload__label">{{ label }}</span>
|
|
60
|
+
<span v-if="hint" class="k-upload__hint">{{ hint }}</span>
|
|
61
|
+
</div>
|
|
62
|
+
<input v-bind="api.getHiddenInputProps()" />
|
|
63
|
+
|
|
64
|
+
<ul v-if="api.acceptedFiles.length" class="k-upload__list" v-bind="api.getItemGroupProps()">
|
|
65
|
+
<li
|
|
66
|
+
v-for="file in api.acceptedFiles"
|
|
67
|
+
:key="`${file.name}-${file.size}-${file.lastModified}`"
|
|
68
|
+
class="k-upload__file"
|
|
69
|
+
v-bind="api.getItemProps({ file })"
|
|
70
|
+
>
|
|
71
|
+
<KIcon name="document" :size="16" class="k-upload__file-icon" />
|
|
72
|
+
<span class="k-upload__file-name" v-bind="api.getItemNameProps({ file })">{{ file.name }}</span>
|
|
73
|
+
<span class="k-upload__file-size" v-bind="api.getItemSizeTextProps({ file })">{{ api.getFileSize(file) }}</span>
|
|
74
|
+
<button class="k-upload__remove" aria-label="Удалить" v-bind="api.getItemDeleteTriggerProps({ file })">
|
|
75
|
+
<KIcon name="close" :size="14" />
|
|
76
|
+
</button>
|
|
77
|
+
</li>
|
|
78
|
+
</ul>
|
|
79
|
+
</div>
|
|
80
|
+
</template>
|
|
81
|
+
|
|
82
|
+
<style scoped>
|
|
83
|
+
.k-upload__zone {
|
|
84
|
+
display: flex;
|
|
85
|
+
flex-direction: column;
|
|
86
|
+
align-items: center;
|
|
87
|
+
gap: 0.2rem;
|
|
88
|
+
width: 100%;
|
|
89
|
+
padding: 1.25rem;
|
|
90
|
+
background: var(--k-surface-muted);
|
|
91
|
+
border: 1px dashed var(--k-border-strong);
|
|
92
|
+
border-radius: var(--k-radius);
|
|
93
|
+
color: var(--k-text-muted);
|
|
94
|
+
cursor: pointer;
|
|
95
|
+
outline: none;
|
|
96
|
+
transition: border-color 0.15s, background 0.15s;
|
|
97
|
+
}
|
|
98
|
+
.k-upload__zone:hover:not([data-disabled]),
|
|
99
|
+
.k-upload__zone[data-dragging],
|
|
100
|
+
.k-upload__zone:focus-visible { border-color: var(--k-primary); background: var(--k-primary-soft); }
|
|
101
|
+
.k-upload__zone[data-disabled] { opacity: 0.6; cursor: not-allowed; }
|
|
102
|
+
.k-upload__icon { color: var(--k-text-faint); }
|
|
103
|
+
.k-upload__label { font-size: 0.875rem; color: var(--k-text); }
|
|
104
|
+
.k-upload__hint { font-size: 0.75rem; }
|
|
105
|
+
.k-upload__list { margin: 0.625rem 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 0.375rem; }
|
|
106
|
+
.k-upload__file {
|
|
107
|
+
display: flex;
|
|
108
|
+
align-items: center;
|
|
109
|
+
gap: 0.5rem;
|
|
110
|
+
padding: 0.4rem 0.6rem;
|
|
111
|
+
background: var(--k-surface);
|
|
112
|
+
border: 1px solid var(--k-border);
|
|
113
|
+
border-radius: var(--k-radius-sm);
|
|
114
|
+
font-size: 0.875rem;
|
|
115
|
+
}
|
|
116
|
+
.k-upload__file-icon { color: var(--k-text-faint); }
|
|
117
|
+
.k-upload__file-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
118
|
+
.k-upload__file-size { font-size: 0.75rem; color: var(--k-text-muted); }
|
|
119
|
+
.k-upload__remove { background: none; border: none; color: var(--k-text-faint); cursor: pointer; padding: 0.1rem; }
|
|
120
|
+
.k-upload__remove:hover { color: var(--k-danger); }
|
|
121
|
+
</style>
|