@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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +235 -2
  3. package/dist/ui-kit.css +2 -0
  4. package/dist/ui-kit.js +15222 -0
  5. package/dist/ui-kit.umd.cjs +3 -0
  6. package/package.json +74 -3
  7. package/src/base.css +32 -0
  8. package/src/components/KAlert.vue +65 -0
  9. package/src/components/KAvatar.vue +67 -0
  10. package/src/components/KBadge.vue +47 -0
  11. package/src/components/KButton.vue +98 -0
  12. package/src/components/KCard.vue +63 -0
  13. package/src/components/KCheckbox.vue +105 -0
  14. package/src/components/KCombobox.vue +232 -0
  15. package/src/components/KCountdown.vue +74 -0
  16. package/src/components/KDescriptions.vue +40 -0
  17. package/src/components/KDropdown.vue +111 -0
  18. package/src/components/KEmpty.vue +39 -0
  19. package/src/components/KFileUpload.vue +121 -0
  20. package/src/components/KFilterBar.vue +91 -0
  21. package/src/components/KGantt.vue +398 -0
  22. package/src/components/KIcon.vue +53 -0
  23. package/src/components/KInput.vue +108 -0
  24. package/src/components/KMenuItem.vue +56 -0
  25. package/src/components/KModal.vue +136 -0
  26. package/src/components/KMoney.vue +44 -0
  27. package/src/components/KPage.vue +93 -0
  28. package/src/components/KPagination.vue +113 -0
  29. package/src/components/KProgress.vue +63 -0
  30. package/src/components/KRadioGroup.vue +103 -0
  31. package/src/components/KSegmented.vue +88 -0
  32. package/src/components/KSelect.vue +231 -0
  33. package/src/components/KShell.vue +176 -0
  34. package/src/components/KStat.vue +74 -0
  35. package/src/components/KSteps.vue +119 -0
  36. package/src/components/KSwitch.vue +82 -0
  37. package/src/components/KTable.vue +161 -0
  38. package/src/components/KTabs.vue +102 -0
  39. package/src/components/KTextarea.vue +79 -0
  40. package/src/components/KTimeline.vue +72 -0
  41. package/src/components/KToast.vue +34 -0
  42. package/src/components/KToastItem.vue +86 -0
  43. package/src/components/KTooltip.vue +67 -0
  44. package/src/components/icon-registry.js +143 -0
  45. package/src/composables/useToast.js +52 -0
  46. package/src/format.js +137 -0
  47. package/src/index.js +85 -0
  48. package/src/theme.js +417 -0
  49. 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>