@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,82 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KSwitch — переключатель булевой настройки на @zag-js/switch. Семантически
|
|
4
|
+
* это checkbox с role="switch"; скрытый input остаётся в форме и в фокусе.
|
|
5
|
+
*/
|
|
6
|
+
import * as zagSwitch from '@zag-js/switch'
|
|
7
|
+
import { normalizeProps, useMachine } from '@zag-js/vue'
|
|
8
|
+
import { computed, useId } from 'vue'
|
|
9
|
+
|
|
10
|
+
const props = defineProps({
|
|
11
|
+
modelValue: { type: Boolean, default: false },
|
|
12
|
+
label: { type: String, default: '' },
|
|
13
|
+
hint: { type: String, default: '' },
|
|
14
|
+
name: { type: String, default: '' },
|
|
15
|
+
disabled: { type: Boolean, default: false },
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits(['update:modelValue'])
|
|
19
|
+
|
|
20
|
+
const service = useMachine(zagSwitch.machine, {
|
|
21
|
+
id: useId(),
|
|
22
|
+
get checked() {
|
|
23
|
+
return props.modelValue
|
|
24
|
+
},
|
|
25
|
+
get disabled() {
|
|
26
|
+
return props.disabled
|
|
27
|
+
},
|
|
28
|
+
get name() {
|
|
29
|
+
return props.name || undefined
|
|
30
|
+
},
|
|
31
|
+
onCheckedChange({ checked }) {
|
|
32
|
+
emit('update:modelValue', checked)
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const api = computed(() => zagSwitch.connect(service, normalizeProps))
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<label class="k-switch" v-bind="api.getRootProps()">
|
|
41
|
+
<input v-bind="api.getHiddenInputProps()" />
|
|
42
|
+
<span class="k-switch__track" v-bind="api.getControlProps()">
|
|
43
|
+
<span class="k-switch__thumb" v-bind="api.getThumbProps()" />
|
|
44
|
+
</span>
|
|
45
|
+
<span v-if="label || hint || $slots.default" class="k-switch__body">
|
|
46
|
+
<span class="k-switch__label" v-bind="api.getLabelProps()"><slot>{{ label }}</slot></span>
|
|
47
|
+
<span v-if="hint" class="k-switch__hint">{{ hint }}</span>
|
|
48
|
+
</span>
|
|
49
|
+
</label>
|
|
50
|
+
</template>
|
|
51
|
+
|
|
52
|
+
<style scoped>
|
|
53
|
+
.k-switch { display: inline-flex; align-items: flex-start; gap: 0.625rem; margin: 0; cursor: pointer; font-weight: 400; }
|
|
54
|
+
.k-switch[data-disabled] { cursor: not-allowed; opacity: 0.6; }
|
|
55
|
+
.k-switch__track {
|
|
56
|
+
position: relative;
|
|
57
|
+
width: 2.25rem;
|
|
58
|
+
height: 1.3rem;
|
|
59
|
+
margin-top: 0.1rem;
|
|
60
|
+
background: var(--k-border-strong);
|
|
61
|
+
border-radius: 999px;
|
|
62
|
+
flex: none;
|
|
63
|
+
transition: background 0.15s, box-shadow 0.15s;
|
|
64
|
+
}
|
|
65
|
+
.k-switch__track[data-state="checked"] { background: var(--k-primary); }
|
|
66
|
+
.k-switch__track[data-focus-visible] { box-shadow: 0 0 0 3px var(--k-primary-soft); }
|
|
67
|
+
.k-switch__thumb {
|
|
68
|
+
position: absolute;
|
|
69
|
+
top: 2px;
|
|
70
|
+
left: 2px;
|
|
71
|
+
width: 1.05rem;
|
|
72
|
+
height: 1.05rem;
|
|
73
|
+
background: var(--k-surface);
|
|
74
|
+
border-radius: 50%;
|
|
75
|
+
box-shadow: var(--k-shadow-sm);
|
|
76
|
+
transition: transform 0.15s;
|
|
77
|
+
}
|
|
78
|
+
.k-switch__thumb[data-state="checked"] { transform: translateX(0.95rem); }
|
|
79
|
+
.k-switch__body { display: flex; flex-direction: column; gap: 0.1rem; }
|
|
80
|
+
.k-switch__label { font-size: 0.9375rem; }
|
|
81
|
+
.k-switch__hint { font-size: 0.75rem; color: var(--k-text-muted); }
|
|
82
|
+
</style>
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KTable — таблица данных с сортировкой, пустым состоянием и пагинацией.
|
|
4
|
+
*
|
|
5
|
+
* Колонки описываются спецификацией, ячейки по умолчанию берут row[col.key];
|
|
6
|
+
* любую можно переопределить слотом `#cell-<key>`. Пагинация контролируемая
|
|
7
|
+
* (серверная): передайте total + pageSize и v-model:page.
|
|
8
|
+
*
|
|
9
|
+
* columns: [{ key, label, width?, align?, nowrap?, sortable?, actions? }]
|
|
10
|
+
*
|
|
11
|
+
* <KTable :columns="cols" :rows="rows" v-model:page="page"
|
|
12
|
+
* :page-size="20" :total="total" clickable @row-click="open">
|
|
13
|
+
* <template #cell-status="{ row }"><KBadge>{{ row.status }}</KBadge></template>
|
|
14
|
+
* </KTable>
|
|
15
|
+
*/
|
|
16
|
+
import { computed } from 'vue'
|
|
17
|
+
import KIcon from './KIcon.vue'
|
|
18
|
+
import KPagination from './KPagination.vue'
|
|
19
|
+
|
|
20
|
+
const props = defineProps({
|
|
21
|
+
columns: { type: Array, required: true },
|
|
22
|
+
rows: { type: Array, default: () => [] },
|
|
23
|
+
rowKey: { type: [String, Function], default: 'id' },
|
|
24
|
+
loading: { type: Boolean, default: false },
|
|
25
|
+
emptyText: { type: String, default: 'Ничего не найдено' },
|
|
26
|
+
clickable: { type: Boolean, default: false },
|
|
27
|
+
dense: { type: Boolean, default: false },
|
|
28
|
+
sortKey: { type: String, default: '' },
|
|
29
|
+
sortDir: { type: String, default: 'asc' }, // asc | desc
|
|
30
|
+
page: { type: Number, default: 1 },
|
|
31
|
+
pageSize: { type: Number, default: 0 },
|
|
32
|
+
total: { type: Number, default: 0 },
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const emit = defineEmits(['update:page', 'update:sortKey', 'update:sortDir', 'row-click'])
|
|
36
|
+
|
|
37
|
+
function keyOf(row, i) {
|
|
38
|
+
return typeof props.rowKey === 'function' ? props.rowKey(row) : (row[props.rowKey] ?? i)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toggleSort(col) {
|
|
42
|
+
if (!col.sortable) return
|
|
43
|
+
if (props.sortKey === col.key) {
|
|
44
|
+
emit('update:sortDir', props.sortDir === 'asc' ? 'desc' : 'asc')
|
|
45
|
+
} else {
|
|
46
|
+
emit('update:sortKey', col.key)
|
|
47
|
+
emit('update:sortDir', 'asc')
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const showPagination = computed(() => props.pageSize > 0 && props.total > props.pageSize)
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<template>
|
|
55
|
+
<div class="k-table" :class="{ 'k-table--dense': dense }">
|
|
56
|
+
<div class="k-table__scroll">
|
|
57
|
+
<table class="k-table__table">
|
|
58
|
+
<thead>
|
|
59
|
+
<tr>
|
|
60
|
+
<th
|
|
61
|
+
v-for="col in columns"
|
|
62
|
+
:key="col.key"
|
|
63
|
+
:style="{ width: col.width, textAlign: col.align || 'left' }"
|
|
64
|
+
:class="{ 'k-table__th--sortable': col.sortable }"
|
|
65
|
+
@click="toggleSort(col)"
|
|
66
|
+
>
|
|
67
|
+
<span class="k-table__th-inner">
|
|
68
|
+
{{ col.label }}
|
|
69
|
+
<KIcon
|
|
70
|
+
v-if="col.sortable"
|
|
71
|
+
:name="sortKey === col.key && sortDir === 'desc' ? 'chevron-down' : 'chevron-up'"
|
|
72
|
+
:size="13"
|
|
73
|
+
:class="['k-table__sort', { 'k-table__sort--active': sortKey === col.key }]"
|
|
74
|
+
/>
|
|
75
|
+
</span>
|
|
76
|
+
</th>
|
|
77
|
+
</tr>
|
|
78
|
+
</thead>
|
|
79
|
+
<tbody>
|
|
80
|
+
<tr v-if="loading">
|
|
81
|
+
<td :colspan="columns.length" class="k-table__msg">
|
|
82
|
+
<KIcon name="spinner" :size="18" spin /> Загрузка…
|
|
83
|
+
</td>
|
|
84
|
+
</tr>
|
|
85
|
+
<tr v-else-if="!rows.length">
|
|
86
|
+
<td :colspan="columns.length" class="k-table__msg">
|
|
87
|
+
<slot name="empty">{{ emptyText }}</slot>
|
|
88
|
+
</td>
|
|
89
|
+
</tr>
|
|
90
|
+
<tr
|
|
91
|
+
v-for="(row, i) in rows"
|
|
92
|
+
v-else
|
|
93
|
+
:key="keyOf(row, i)"
|
|
94
|
+
:class="{ 'k-table__row--clickable': clickable }"
|
|
95
|
+
@click="clickable && emit('row-click', row)"
|
|
96
|
+
>
|
|
97
|
+
<td
|
|
98
|
+
v-for="col in columns"
|
|
99
|
+
:key="col.key"
|
|
100
|
+
:style="{ textAlign: col.align || 'left', whiteSpace: col.nowrap ? 'nowrap' : null }"
|
|
101
|
+
>
|
|
102
|
+
<div v-if="col.actions" class="k-table__actions" @click.stop>
|
|
103
|
+
<slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]" :index="i" />
|
|
104
|
+
</div>
|
|
105
|
+
<slot v-else :name="`cell-${col.key}`" :row="row" :value="row[col.key]" :index="i">
|
|
106
|
+
{{ row[col.key] }}
|
|
107
|
+
</slot>
|
|
108
|
+
</td>
|
|
109
|
+
</tr>
|
|
110
|
+
</tbody>
|
|
111
|
+
</table>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<KPagination
|
|
115
|
+
v-if="showPagination"
|
|
116
|
+
:page="page"
|
|
117
|
+
:page-size="pageSize"
|
|
118
|
+
:total="total"
|
|
119
|
+
@update:page="emit('update:page', $event)"
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
</template>
|
|
123
|
+
|
|
124
|
+
<style scoped>
|
|
125
|
+
.k-table__scroll { overflow-x: auto; }
|
|
126
|
+
.k-table__table { width: 100%; border-collapse: collapse; font-size: 0.9375rem; }
|
|
127
|
+
.k-table__table th,
|
|
128
|
+
.k-table__table td {
|
|
129
|
+
padding: 0.7rem 0.875rem;
|
|
130
|
+
text-align: left;
|
|
131
|
+
border-bottom: 1px solid var(--k-border);
|
|
132
|
+
vertical-align: middle;
|
|
133
|
+
}
|
|
134
|
+
.k-table--dense .k-table__table th,
|
|
135
|
+
.k-table--dense .k-table__table td { padding: 0.45rem 0.75rem; font-size: 0.875rem; }
|
|
136
|
+
.k-table__table th {
|
|
137
|
+
position: sticky;
|
|
138
|
+
top: 0;
|
|
139
|
+
background: var(--k-surface-muted);
|
|
140
|
+
font-size: 0.6875rem;
|
|
141
|
+
font-weight: 600;
|
|
142
|
+
letter-spacing: 0.05em;
|
|
143
|
+
text-transform: uppercase;
|
|
144
|
+
color: var(--k-text-muted);
|
|
145
|
+
white-space: nowrap;
|
|
146
|
+
}
|
|
147
|
+
.k-table__th-inner { display: inline-flex; align-items: center; gap: 0.25rem; }
|
|
148
|
+
.k-table__th--sortable { cursor: pointer; user-select: none; }
|
|
149
|
+
.k-table__th--sortable:hover { color: var(--k-text); }
|
|
150
|
+
.k-table__sort { opacity: 0.35; }
|
|
151
|
+
.k-table__sort--active { opacity: 1; color: var(--k-primary); }
|
|
152
|
+
.k-table__table tbody tr:last-child td { border-bottom: none; }
|
|
153
|
+
.k-table__actions { display: inline-flex; align-items: center; gap: var(--k-gap); }
|
|
154
|
+
.k-table__row--clickable { cursor: pointer; }
|
|
155
|
+
.k-table__row--clickable:hover { background: var(--k-primary-soft); }
|
|
156
|
+
.k-table__msg {
|
|
157
|
+
text-align: center;
|
|
158
|
+
color: var(--k-text-muted);
|
|
159
|
+
padding: 2rem;
|
|
160
|
+
}
|
|
161
|
+
</style>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KTabs — горизонтальные вкладки на @zag-js/tabs: навигация стрелками,
|
|
4
|
+
* roving tabindex и ARIA от машины.
|
|
5
|
+
*
|
|
6
|
+
* Только заголовки: содержимое рисует приложение по v-model. Так вкладки можно
|
|
7
|
+
* привязать к маршруту, не дублируя контент внутри компонента.
|
|
8
|
+
*
|
|
9
|
+
* items: [{ value, label, hint?, icon?, count?, disabled? }]
|
|
10
|
+
*
|
|
11
|
+
* hint — пояснение второй строкой под названием вкладки.
|
|
12
|
+
*/
|
|
13
|
+
import * as tabs from '@zag-js/tabs'
|
|
14
|
+
import { normalizeProps, useMachine } from '@zag-js/vue'
|
|
15
|
+
import { computed, useId } from 'vue'
|
|
16
|
+
import KIcon from './KIcon.vue'
|
|
17
|
+
|
|
18
|
+
const props = defineProps({
|
|
19
|
+
modelValue: { type: [String, Number], default: '' },
|
|
20
|
+
items: { type: Array, default: () => [] },
|
|
21
|
+
variant: { type: String, default: 'line' }, // line | pill
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const emit = defineEmits(['update:modelValue'])
|
|
25
|
+
|
|
26
|
+
const service = useMachine(tabs.machine, {
|
|
27
|
+
id: useId(),
|
|
28
|
+
get value() {
|
|
29
|
+
return props.modelValue === '' || props.modelValue == null ? null : String(props.modelValue)
|
|
30
|
+
},
|
|
31
|
+
onValueChange({ value }) {
|
|
32
|
+
const item = props.items.find((i) => String(i.value) === value)
|
|
33
|
+
emit('update:modelValue', item ? item.value : value)
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const api = computed(() => tabs.connect(service, normalizeProps))
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<div class="k-tabs" :class="`k-tabs--${variant}`" v-bind="api.getRootProps()">
|
|
42
|
+
<div class="k-tabs__list" v-bind="api.getListProps()">
|
|
43
|
+
<button
|
|
44
|
+
v-for="item in items"
|
|
45
|
+
:key="item.value"
|
|
46
|
+
class="k-tabs__tab"
|
|
47
|
+
:class="{ 'k-tabs__tab--hinted': item.hint }"
|
|
48
|
+
v-bind="api.getTriggerProps({ value: String(item.value), disabled: item.disabled })"
|
|
49
|
+
>
|
|
50
|
+
<span class="k-tabs__label">
|
|
51
|
+
<KIcon v-if="item.icon" :name="item.icon" :size="15" />
|
|
52
|
+
{{ item.label }}
|
|
53
|
+
<span v-if="item.count != null" class="k-tabs__count">{{ item.count }}</span>
|
|
54
|
+
</span>
|
|
55
|
+
<span v-if="item.hint" class="k-tabs__hint">{{ item.hint }}</span>
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</template>
|
|
60
|
+
|
|
61
|
+
<style scoped>
|
|
62
|
+
.k-tabs { overflow-x: auto; }
|
|
63
|
+
.k-tabs__list { display: flex; align-items: stretch; gap: 0.25rem; }
|
|
64
|
+
.k-tabs--line .k-tabs__list { border-bottom: 1px solid var(--k-border); }
|
|
65
|
+
.k-tabs__tab {
|
|
66
|
+
display: inline-flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
gap: 0.4rem;
|
|
69
|
+
padding: 0.6rem 0.75rem;
|
|
70
|
+
background: none;
|
|
71
|
+
border: none;
|
|
72
|
+
font: inherit;
|
|
73
|
+
font-size: 0.9375rem;
|
|
74
|
+
font-weight: 500;
|
|
75
|
+
color: var(--k-text-muted);
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
white-space: nowrap;
|
|
78
|
+
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
|
79
|
+
}
|
|
80
|
+
.k-tabs__tab:focus { outline: none; }
|
|
81
|
+
.k-tabs__tab:focus-visible { outline: 2px solid var(--k-primary); outline-offset: -2px; border-radius: var(--k-radius-sm); }
|
|
82
|
+
.k-tabs__tab[data-disabled] { opacity: 0.5; cursor: not-allowed; }
|
|
83
|
+
.k-tabs--line .k-tabs__tab { border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
|
84
|
+
.k-tabs--line .k-tabs__tab:hover:not([data-disabled]) { color: var(--k-text); }
|
|
85
|
+
.k-tabs--line .k-tabs__tab[data-selected] { color: var(--k-primary); border-bottom-color: var(--k-primary); }
|
|
86
|
+
.k-tabs--pill .k-tabs__tab { border-radius: 999px; padding: 0.4rem 0.85rem; }
|
|
87
|
+
.k-tabs--pill .k-tabs__tab:hover:not([data-disabled]) { background: var(--k-surface-muted); }
|
|
88
|
+
.k-tabs--pill .k-tabs__tab[data-selected] { background: var(--k-primary-soft); color: var(--k-primary); }
|
|
89
|
+
.k-tabs__label { display: inline-flex; align-items: center; gap: 0.4rem; }
|
|
90
|
+
.k-tabs__tab--hinted { flex-direction: column; align-items: flex-start; justify-content: center; gap: 0.1rem; }
|
|
91
|
+
.k-tabs__hint { font-size: 0.75rem; font-weight: 400; color: var(--k-text-muted); }
|
|
92
|
+
.k-tabs--pill .k-tabs__tab--hinted { border-radius: var(--k-radius-lg); }
|
|
93
|
+
.k-tabs__count {
|
|
94
|
+
padding: 0.05rem 0.35rem;
|
|
95
|
+
border-radius: 999px;
|
|
96
|
+
background: var(--k-surface-muted);
|
|
97
|
+
font-size: 0.6875rem;
|
|
98
|
+
font-weight: 600;
|
|
99
|
+
color: var(--k-text-muted);
|
|
100
|
+
}
|
|
101
|
+
.k-tabs__tab[data-selected] .k-tabs__count { background: var(--k-primary-soft); color: var(--k-primary); }
|
|
102
|
+
</style>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KTextarea — многострочное поле. Подпись, подсказка и ошибка ведут себя так
|
|
4
|
+
* же, как у KInput, чтобы формы выглядели однородно.
|
|
5
|
+
*/
|
|
6
|
+
import { computed, useAttrs } from 'vue'
|
|
7
|
+
|
|
8
|
+
defineOptions({ inheritAttrs: false })
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* Атрибуты разделяются: class и style остаются на корне (иначе layout-классы
|
|
12
|
+
* из родителя не могут задать ширину поля в строке фильтров), остальное —
|
|
13
|
+
* autocomplete, inputmode, min, обработчики — уходит на сам контрол.
|
|
14
|
+
*/
|
|
15
|
+
const attrs = useAttrs()
|
|
16
|
+
const rootAttrs = computed(() => ({ class: attrs.class, style: attrs.style }))
|
|
17
|
+
const controlAttrs = computed(() => {
|
|
18
|
+
const { class: _c, style: _s, ...rest } = attrs
|
|
19
|
+
return rest
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
defineProps({
|
|
23
|
+
modelValue: { type: String, default: '' },
|
|
24
|
+
label: { type: String, default: '' },
|
|
25
|
+
placeholder: { type: String, default: '' },
|
|
26
|
+
rows: { type: [Number, String], default: 4 },
|
|
27
|
+
hint: { type: String, default: '' },
|
|
28
|
+
error: { type: String, default: '' },
|
|
29
|
+
required: { type: Boolean, default: false },
|
|
30
|
+
disabled: { type: Boolean, default: false },
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
defineEmits(['update:modelValue'])
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<label class="k-textarea" v-bind="rootAttrs">
|
|
38
|
+
<span v-if="label" class="k-textarea__label">
|
|
39
|
+
{{ label }}<span v-if="required" class="k-textarea__req">*</span>
|
|
40
|
+
</span>
|
|
41
|
+
<textarea
|
|
42
|
+
class="k-textarea__field"
|
|
43
|
+
:class="{ 'k-textarea__field--error': error }"
|
|
44
|
+
:value="modelValue"
|
|
45
|
+
:rows="rows"
|
|
46
|
+
:placeholder="placeholder"
|
|
47
|
+
:required="required"
|
|
48
|
+
:disabled="disabled"
|
|
49
|
+
v-bind="controlAttrs"
|
|
50
|
+
@input="$emit('update:modelValue', $event.target.value)"
|
|
51
|
+
/>
|
|
52
|
+
<span v-if="error" class="k-textarea__error">{{ error }}</span>
|
|
53
|
+
<span v-else-if="hint" class="k-textarea__hint">{{ hint }}</span>
|
|
54
|
+
</label>
|
|
55
|
+
</template>
|
|
56
|
+
|
|
57
|
+
<style scoped>
|
|
58
|
+
.k-textarea { display: flex; flex-direction: column; gap: 0.25rem; margin: 0; min-width: 0; }
|
|
59
|
+
.k-textarea__label { font-size: 0.8125rem; color: var(--k-text-muted); }
|
|
60
|
+
.k-textarea__req { color: var(--k-danger); margin-left: 2px; }
|
|
61
|
+
.k-textarea__field {
|
|
62
|
+
width: 100%;
|
|
63
|
+
padding: 0.5rem 0.625rem;
|
|
64
|
+
background: var(--k-surface);
|
|
65
|
+
border: 1px solid var(--k-border-strong);
|
|
66
|
+
border-radius: var(--k-radius-sm);
|
|
67
|
+
font: inherit;
|
|
68
|
+
font-size: 0.9375rem;
|
|
69
|
+
color: inherit;
|
|
70
|
+
outline: none;
|
|
71
|
+
resize: vertical;
|
|
72
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
73
|
+
}
|
|
74
|
+
.k-textarea__field:focus { border-color: var(--k-primary); box-shadow: 0 0 0 3px var(--k-primary-soft); }
|
|
75
|
+
.k-textarea__field--error { border-color: var(--k-danger); }
|
|
76
|
+
.k-textarea__field:disabled { background: var(--k-surface-muted); cursor: not-allowed; }
|
|
77
|
+
.k-textarea__hint { font-size: 0.75rem; color: var(--k-text-muted); }
|
|
78
|
+
.k-textarea__error { font-size: 0.75rem; color: var(--k-danger); }
|
|
79
|
+
</style>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KTimeline — хронология событий: история статусов, журнал действий, лента
|
|
4
|
+
* активности.
|
|
5
|
+
*
|
|
6
|
+
* items: [{ time, title, text?, tone?, icon? }]
|
|
7
|
+
*/
|
|
8
|
+
import KIcon from './KIcon.vue'
|
|
9
|
+
|
|
10
|
+
defineProps({
|
|
11
|
+
items: { type: Array, default: () => [] },
|
|
12
|
+
})
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<ol class="k-timeline">
|
|
17
|
+
<li v-for="(item, i) in items" :key="i" class="k-timeline__item">
|
|
18
|
+
<span class="k-timeline__marker" :class="`k-timeline__marker--${item.tone || 'neutral'}`">
|
|
19
|
+
<KIcon v-if="item.icon" :name="item.icon" :size="12" />
|
|
20
|
+
</span>
|
|
21
|
+
<div class="k-timeline__body">
|
|
22
|
+
<div class="k-timeline__head">
|
|
23
|
+
<span class="k-timeline__title">
|
|
24
|
+
<slot name="title" :item="item">{{ item.title }}</slot>
|
|
25
|
+
</span>
|
|
26
|
+
<span class="k-timeline__time">{{ item.time }}</span>
|
|
27
|
+
</div>
|
|
28
|
+
<p v-if="item.text" class="k-timeline__text">{{ item.text }}</p>
|
|
29
|
+
<slot name="extra" :item="item" />
|
|
30
|
+
</div>
|
|
31
|
+
</li>
|
|
32
|
+
</ol>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<style scoped>
|
|
36
|
+
.k-timeline { margin: 0; padding: 0; list-style: none; }
|
|
37
|
+
.k-timeline__item { position: relative; display: flex; gap: 0.75rem; padding-bottom: 1rem; }
|
|
38
|
+
/* Вертикальная линия между маркерами; у последнего элемента не рисуем. */
|
|
39
|
+
.k-timeline__item:not(:last-child)::before {
|
|
40
|
+
content: '';
|
|
41
|
+
position: absolute;
|
|
42
|
+
left: 0.53rem;
|
|
43
|
+
top: 1.35rem;
|
|
44
|
+
bottom: 0.1rem;
|
|
45
|
+
width: 1px;
|
|
46
|
+
background: var(--k-border);
|
|
47
|
+
}
|
|
48
|
+
.k-timeline__item:last-child { padding-bottom: 0; }
|
|
49
|
+
.k-timeline__marker {
|
|
50
|
+
position: relative;
|
|
51
|
+
z-index: 1;
|
|
52
|
+
display: inline-flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
justify-content: center;
|
|
55
|
+
width: 1.125rem;
|
|
56
|
+
height: 1.125rem;
|
|
57
|
+
margin-top: 0.2rem;
|
|
58
|
+
border-radius: 50%;
|
|
59
|
+
flex: none;
|
|
60
|
+
border: 2px solid var(--k-surface);
|
|
61
|
+
}
|
|
62
|
+
.k-timeline__marker--neutral { background: var(--k-border-strong); color: var(--k-surface); }
|
|
63
|
+
.k-timeline__marker--primary { background: var(--k-primary); color: var(--k-on-primary); }
|
|
64
|
+
.k-timeline__marker--success { background: var(--k-success); color: var(--k-on-success); }
|
|
65
|
+
.k-timeline__marker--warning { background: var(--k-warning); color: var(--k-on-warning); }
|
|
66
|
+
.k-timeline__marker--danger { background: var(--k-danger); color: var(--k-on-danger); }
|
|
67
|
+
.k-timeline__body { flex: 1; min-width: 0; }
|
|
68
|
+
.k-timeline__head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.75rem; }
|
|
69
|
+
.k-timeline__title { font-size: 0.9375rem; }
|
|
70
|
+
.k-timeline__time { font-size: 0.75rem; color: var(--k-text-faint); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
71
|
+
.k-timeline__text { font-size: 0.8125rem; color: var(--k-text-muted); margin-top: 0.1rem; }
|
|
72
|
+
</style>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KToast — контейнер всплывающих уведомлений на @zag-js/toast.
|
|
4
|
+
*
|
|
5
|
+
* Ставится один раз в корне приложения; сообщения добавляются через
|
|
6
|
+
* useToast() — вызывающему коду доступ к компоненту не нужен.
|
|
7
|
+
*/
|
|
8
|
+
import * as toast from '@zag-js/toast'
|
|
9
|
+
import { normalizeProps, useMachine } from '@zag-js/vue'
|
|
10
|
+
import { computed, useId } from 'vue'
|
|
11
|
+
import KToastItem from './KToastItem.vue'
|
|
12
|
+
import { toastStore } from '../composables/useToast.js'
|
|
13
|
+
|
|
14
|
+
const service = useMachine(toast.group.machine, { id: useId(), store: toastStore })
|
|
15
|
+
const api = computed(() => toast.group.connect(service, normalizeProps))
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<Teleport to="body">
|
|
20
|
+
<div class="k-toasts" v-bind="api.getGroupProps({ label: 'Уведомления' })">
|
|
21
|
+
<KToastItem
|
|
22
|
+
v-for="(item, index) in api.getToasts()"
|
|
23
|
+
:key="item.id"
|
|
24
|
+
:actor="item"
|
|
25
|
+
:index="index"
|
|
26
|
+
:parent="service"
|
|
27
|
+
/>
|
|
28
|
+
</div>
|
|
29
|
+
</Teleport>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<style scoped>
|
|
33
|
+
.k-toasts { z-index: 1300; }
|
|
34
|
+
</style>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KToastItem — одно уведомление внутри KToast. Внутренний компонент: машина
|
|
4
|
+
* тоста получает родительский сервис группы и свой индекс в стопке.
|
|
5
|
+
*/
|
|
6
|
+
import * as toast from '@zag-js/toast'
|
|
7
|
+
import { normalizeProps, useMachine } from '@zag-js/vue'
|
|
8
|
+
import { computed } from 'vue'
|
|
9
|
+
import KIcon from './KIcon.vue'
|
|
10
|
+
|
|
11
|
+
const props = defineProps({
|
|
12
|
+
actor: { type: Object, required: true },
|
|
13
|
+
index: { type: Number, required: true },
|
|
14
|
+
parent: { type: Object, required: true },
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const machineProps = computed(() => ({ ...props.actor, parent: props.parent, index: props.index }))
|
|
18
|
+
|
|
19
|
+
const service = useMachine(toast.machine, machineProps)
|
|
20
|
+
const api = computed(() => toast.connect(service, normalizeProps))
|
|
21
|
+
|
|
22
|
+
const icons = { info: 'info', success: 'check-badge', warning: 'alert', error: 'reject', loading: 'spinner' }
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<div class="k-toast" v-bind="api.getRootProps()">
|
|
27
|
+
<span v-bind="api.getGhostBeforeProps()" />
|
|
28
|
+
<KIcon :name="icons[api.type] || 'info'" :size="18" class="k-toast__icon" :spin="api.type === 'loading'" />
|
|
29
|
+
<div class="k-toast__body">
|
|
30
|
+
<p v-if="api.title" class="k-toast__title" v-bind="api.getTitleProps()">{{ api.title }}</p>
|
|
31
|
+
<p v-if="api.description" class="k-toast__text" v-bind="api.getDescriptionProps()">{{ api.description }}</p>
|
|
32
|
+
</div>
|
|
33
|
+
<button v-if="api.closable" class="k-toast__close" aria-label="Закрыть" v-bind="api.getCloseTriggerProps()">
|
|
34
|
+
<KIcon name="close" :size="14" />
|
|
35
|
+
</button>
|
|
36
|
+
<span v-bind="api.getGhostAfterProps()" />
|
|
37
|
+
</div>
|
|
38
|
+
</template>
|
|
39
|
+
|
|
40
|
+
<style scoped>
|
|
41
|
+
.k-toast {
|
|
42
|
+
/* Координаты и прозрачность приходят от машины в CSS-переменных; здесь они
|
|
43
|
+
только отображаются на свойства, чтобы стопка анимировалась плавно. */
|
|
44
|
+
translate: var(--x) var(--y);
|
|
45
|
+
scale: var(--scale, 1);
|
|
46
|
+
opacity: var(--opacity);
|
|
47
|
+
height: var(--height, auto);
|
|
48
|
+
z-index: var(--z-index);
|
|
49
|
+
will-change: translate, opacity, scale;
|
|
50
|
+
transition:
|
|
51
|
+
translate 400ms cubic-bezier(0.21, 1.02, 0.73, 1),
|
|
52
|
+
scale 400ms,
|
|
53
|
+
opacity 400ms,
|
|
54
|
+
height 400ms;
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: flex-start;
|
|
57
|
+
gap: 0.6rem;
|
|
58
|
+
width: min(24rem, calc(100vw - 2rem));
|
|
59
|
+
padding: 0.75rem 0.875rem;
|
|
60
|
+
background: var(--k-surface);
|
|
61
|
+
border: 1px solid var(--k-border);
|
|
62
|
+
border-left: 3px solid var(--k-primary);
|
|
63
|
+
border-radius: var(--k-radius-sm);
|
|
64
|
+
box-shadow: var(--k-shadow-md);
|
|
65
|
+
color: var(--k-text);
|
|
66
|
+
}
|
|
67
|
+
.k-toast[data-type='success'] { border-left-color: var(--k-success); }
|
|
68
|
+
.k-toast[data-type='warning'] { border-left-color: var(--k-warning); }
|
|
69
|
+
.k-toast[data-type='error'] { border-left-color: var(--k-danger); }
|
|
70
|
+
.k-toast__icon { margin-top: 0.1rem; color: var(--k-text-muted); flex: none; }
|
|
71
|
+
.k-toast[data-type='success'] .k-toast__icon { color: var(--k-success); }
|
|
72
|
+
.k-toast[data-type='warning'] .k-toast__icon { color: var(--k-warning-strong); }
|
|
73
|
+
.k-toast[data-type='error'] .k-toast__icon { color: var(--k-danger); }
|
|
74
|
+
.k-toast__body { flex: 1; min-width: 0; }
|
|
75
|
+
.k-toast__title { font-size: 0.9375rem; font-weight: 600; }
|
|
76
|
+
.k-toast__text { font-size: 0.8125rem; color: var(--k-text-muted); white-space: pre-line; overflow-wrap: break-word; }
|
|
77
|
+
.k-toast__close {
|
|
78
|
+
background: none;
|
|
79
|
+
border: none;
|
|
80
|
+
color: var(--k-text-faint);
|
|
81
|
+
cursor: pointer;
|
|
82
|
+
padding: 0;
|
|
83
|
+
flex: none;
|
|
84
|
+
}
|
|
85
|
+
.k-toast__close:hover { color: var(--k-text); }
|
|
86
|
+
</style>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KTooltip — подсказка над обёрнутым элементом на @zag-js/tooltip: открывается
|
|
4
|
+
* по наведению и фокусу с задержкой, закрывается по Esc и уходу указателя.
|
|
5
|
+
* Пузырёк рендерится в <body>, поэтому не обрезается контейнерами с overflow.
|
|
6
|
+
*
|
|
7
|
+
* <KTooltip text="Расшифровка статуса"><KBadge>В работе</KBadge></KTooltip>
|
|
8
|
+
*/
|
|
9
|
+
import * as tooltip from '@zag-js/tooltip'
|
|
10
|
+
import { normalizeProps, useMachine } from '@zag-js/vue'
|
|
11
|
+
import { computed, useId } from 'vue'
|
|
12
|
+
|
|
13
|
+
const props = defineProps({
|
|
14
|
+
text: { type: String, default: '' },
|
|
15
|
+
placement: { type: String, default: 'top' }, // top | bottom | left | right
|
|
16
|
+
openDelay: { type: Number, default: 300 },
|
|
17
|
+
closeDelay: { type: Number, default: 100 },
|
|
18
|
+
disabled: { type: Boolean, default: false },
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const service = useMachine(tooltip.machine, {
|
|
22
|
+
id: useId(),
|
|
23
|
+
get positioning() {
|
|
24
|
+
return { placement: props.placement }
|
|
25
|
+
},
|
|
26
|
+
get openDelay() {
|
|
27
|
+
return props.openDelay
|
|
28
|
+
},
|
|
29
|
+
get closeDelay() {
|
|
30
|
+
return props.closeDelay
|
|
31
|
+
},
|
|
32
|
+
get disabled() {
|
|
33
|
+
return props.disabled
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const api = computed(() => tooltip.connect(service, normalizeProps))
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<span class="k-tooltip" tabindex="0" v-bind="api.getTriggerProps()">
|
|
42
|
+
<slot />
|
|
43
|
+
</span>
|
|
44
|
+
<Teleport to="body">
|
|
45
|
+
<div v-if="api.open" v-bind="api.getPositionerProps()">
|
|
46
|
+
<div class="k-tooltip__bubble" v-bind="api.getContentProps()">
|
|
47
|
+
<slot name="content">{{ text }}</slot>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</Teleport>
|
|
51
|
+
</template>
|
|
52
|
+
|
|
53
|
+
<style scoped>
|
|
54
|
+
.k-tooltip { display: inline-flex; outline: none; }
|
|
55
|
+
.k-tooltip:focus-visible { outline: 2px solid var(--k-primary); outline-offset: 2px; border-radius: var(--k-radius-sm); }
|
|
56
|
+
.k-tooltip__bubble {
|
|
57
|
+
z-index: 1200;
|
|
58
|
+
max-width: 18rem;
|
|
59
|
+
padding: 0.4rem 0.55rem;
|
|
60
|
+
background: var(--k-tooltip-bg);
|
|
61
|
+
color: var(--k-tooltip-text);
|
|
62
|
+
border-radius: var(--k-radius-sm);
|
|
63
|
+
font-size: 0.75rem;
|
|
64
|
+
line-height: 1.4;
|
|
65
|
+
box-shadow: var(--k-shadow-md);
|
|
66
|
+
}
|
|
67
|
+
</style>
|