@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,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>
@@ -0,0 +1,176 @@
1
+ <script setup>
2
+ /**
3
+ * KShell — каркас приложения: светлый боковой навигатор + верхняя шапка.
4
+ *
5
+ * Сайдбар намеренно светлый: в интерфейсах с плотными таблицами тёмная колонка
6
+ * перетягивает внимание с данных. Цвета берутся из темы, поэтому под конкретный
7
+ * проект каркас перекрашивается без правок разметки.
8
+ *
9
+ * nav: [{ section?, items: [{ label, to, icon?, badge? }] }]
10
+ *
11
+ * Слоты: #logo, #header-actions, #user, по умолчанию — контент страницы.
12
+ */
13
+ import { ref } from 'vue'
14
+ import KIcon from './KIcon.vue'
15
+
16
+ defineProps({
17
+ nav: { type: Array, default: () => [] },
18
+ title: { type: String, default: '' },
19
+ })
20
+
21
+ const open = ref(false)
22
+ </script>
23
+
24
+ <template>
25
+ <div class="k-shell" :class="{ 'k-shell--nav-open': open }">
26
+ <aside class="k-shell__sidebar">
27
+ <div class="k-shell__logo">
28
+ <slot name="logo">{{ title }}</slot>
29
+ </div>
30
+ <nav class="k-shell__nav">
31
+ <template v-for="(group, gi) in nav" :key="gi">
32
+ <p v-if="group.section" class="k-shell__section">{{ group.section }}</p>
33
+ <router-link
34
+ v-for="item in group.items"
35
+ :key="item.to"
36
+ :to="item.to"
37
+ class="k-shell__link"
38
+ @click="open = false"
39
+ >
40
+ <KIcon v-if="item.icon" :name="item.icon" :size="17" class="k-shell__link-icon" />
41
+ <span class="k-shell__link-label">{{ item.label }}</span>
42
+ <span v-if="item.badge" class="k-shell__badge">{{ item.badge }}</span>
43
+ </router-link>
44
+ </template>
45
+ </nav>
46
+ <div v-if="$slots.sidebarFooter" class="k-shell__sidebar-footer">
47
+ <slot name="sidebarFooter" />
48
+ </div>
49
+ </aside>
50
+
51
+ <div class="k-shell__main">
52
+ <header class="k-shell__header">
53
+ <button class="k-shell__burger" aria-label="Меню" @click="open = !open">
54
+ <KIcon :name="open ? 'close' : 'menu'" :size="20" />
55
+ </button>
56
+ <div class="k-shell__header-left"><slot name="header-left" /></div>
57
+ <div class="k-shell__header-actions">
58
+ <slot name="header-actions" />
59
+ <slot name="user" />
60
+ </div>
61
+ </header>
62
+ <main class="k-shell__content"><slot /></main>
63
+ </div>
64
+
65
+ <div class="k-shell__overlay" @click="open = false" />
66
+ </div>
67
+ </template>
68
+
69
+ <style scoped>
70
+ .k-shell { display: flex; min-height: 100vh; background: var(--k-bg); }
71
+ .k-shell__sidebar {
72
+ width: 15rem;
73
+ flex: none;
74
+ display: flex;
75
+ flex-direction: column;
76
+ background: var(--k-surface);
77
+ border-right: 1px solid var(--k-border);
78
+ position: sticky;
79
+ top: 0;
80
+ height: 100vh;
81
+ }
82
+ .k-shell__logo {
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 0.55rem;
86
+ height: 3.75rem;
87
+ padding: 0 1.125rem;
88
+ border-bottom: 1px solid var(--k-border);
89
+ font-size: 1.0625rem;
90
+ font-weight: 650;
91
+ letter-spacing: -0.01em;
92
+ }
93
+ .k-shell__nav { flex: 1; overflow-y: auto; padding: 0.75rem 0.625rem; }
94
+ .k-shell__section {
95
+ padding: 0.9rem 0.55rem 0.35rem;
96
+ font-size: 0.6875rem;
97
+ font-weight: 600;
98
+ letter-spacing: 0.06em;
99
+ text-transform: uppercase;
100
+ color: var(--k-text-faint);
101
+ }
102
+ .k-shell__link {
103
+ display: flex;
104
+ align-items: center;
105
+ gap: 0.6rem;
106
+ padding: 0.5rem 0.55rem;
107
+ border-radius: var(--k-radius-sm);
108
+ color: var(--k-text-muted);
109
+ font-size: 0.9375rem;
110
+ text-decoration: none;
111
+ transition: background 0.12s, color 0.12s;
112
+ }
113
+ .k-shell__link:hover { background: var(--k-surface-muted); color: var(--k-text); text-decoration: none; }
114
+ .k-shell__link.router-link-active {
115
+ background: var(--k-primary-soft);
116
+ color: var(--k-primary);
117
+ font-weight: 550;
118
+ }
119
+ .k-shell__link-icon { color: currentColor; }
120
+ .k-shell__link-label { flex: 1; min-width: 0; }
121
+ .k-shell__badge {
122
+ padding: 0.05rem 0.35rem;
123
+ border-radius: 999px;
124
+ background: var(--k-danger);
125
+ color: var(--k-on-danger);
126
+ font-size: 0.6875rem;
127
+ font-weight: 600;
128
+ }
129
+ .k-shell__sidebar-footer { padding: 0.75rem; border-top: 1px solid var(--k-border); }
130
+
131
+ .k-shell__main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
132
+ .k-shell__header {
133
+ position: sticky;
134
+ top: 0;
135
+ z-index: 40;
136
+ display: flex;
137
+ align-items: center;
138
+ gap: 0.75rem;
139
+ height: 3.75rem;
140
+ padding: 0 1.25rem;
141
+ background: color-mix(in srgb, var(--k-surface) 85%, transparent);
142
+ backdrop-filter: blur(8px);
143
+ border-bottom: 1px solid var(--k-border);
144
+ }
145
+ .k-shell__header-left { flex: 1; min-width: 0; display: flex; align-items: center; gap: 0.75rem; }
146
+ .k-shell__header-actions { display: flex; align-items: center; gap: 0.5rem; }
147
+ .k-shell__burger {
148
+ display: none;
149
+ background: none;
150
+ border: none;
151
+ color: var(--k-text-muted);
152
+ cursor: pointer;
153
+ padding: 0.25rem;
154
+ }
155
+ .k-shell__content { flex: 1; min-width: 0; }
156
+ .k-shell__overlay { display: none; }
157
+
158
+ @media (max-width: 900px) {
159
+ .k-shell__sidebar {
160
+ position: fixed;
161
+ z-index: 70;
162
+ transform: translateX(-100%);
163
+ transition: transform 0.2s ease;
164
+ box-shadow: var(--k-shadow-lg);
165
+ }
166
+ .k-shell--nav-open .k-shell__sidebar { transform: none; }
167
+ .k-shell--nav-open .k-shell__overlay {
168
+ display: block;
169
+ position: fixed;
170
+ inset: 0;
171
+ z-index: 60;
172
+ background: var(--k-overlay);
173
+ }
174
+ .k-shell__burger { display: inline-flex; }
175
+ }
176
+ </style>
@@ -0,0 +1,74 @@
1
+ <script setup>
2
+ /**
3
+ * KStat — плитка ключевой метрики для дашборда.
4
+ *
5
+ * `trend` — изменение в процентах: положительное красится в зелёный со стрелкой
6
+ * вверх, отрицательное в красный. Для метрик, где снижение это хорошо (расходы,
7
+ * время отклика, число ошибок), передайте `invert`.
8
+ */
9
+ import { computed } from 'vue'
10
+ import KIcon from './KIcon.vue'
11
+
12
+ const props = defineProps({
13
+ label: { type: String, default: '' },
14
+ value: { type: [String, Number], default: '' },
15
+ hint: { type: String, default: '' },
16
+ icon: { type: String, default: '' },
17
+ tone: { type: String, default: 'neutral' }, // neutral | primary | success | warning | danger
18
+ trend: { type: Number, default: null },
19
+ invert: { type: Boolean, default: false },
20
+ })
21
+
22
+ const good = computed(() => (props.invert ? props.trend < 0 : props.trend > 0))
23
+ </script>
24
+
25
+ <template>
26
+ <div class="k-stat">
27
+ <div class="k-stat__head">
28
+ <span class="k-stat__label">{{ label }}</span>
29
+ <span v-if="icon" class="k-stat__icon" :class="`k-stat__icon--${tone}`">
30
+ <KIcon :name="icon" :size="16" />
31
+ </span>
32
+ </div>
33
+ <div class="k-stat__value"><slot>{{ value }}</slot></div>
34
+ <div v-if="hint || trend != null" class="k-stat__foot">
35
+ <span v-if="trend != null" class="k-stat__trend" :class="good ? 'k-stat__trend--good' : 'k-stat__trend--bad'">
36
+ <KIcon :name="trend > 0 ? 'up' : 'down'" :size="13" />
37
+ {{ Math.abs(trend).toLocaleString('ru-RU') }}%
38
+ </span>
39
+ <span v-if="hint" class="k-stat__hint">{{ hint }}</span>
40
+ </div>
41
+ </div>
42
+ </template>
43
+
44
+ <style scoped>
45
+ .k-stat {
46
+ background: var(--k-surface);
47
+ border: 1px solid var(--k-border);
48
+ border-radius: var(--k-radius);
49
+ box-shadow: var(--k-shadow-sm);
50
+ padding: 1rem 1.125rem;
51
+ }
52
+ .k-stat__head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
53
+ .k-stat__label { font-size: 0.8125rem; color: var(--k-text-muted); }
54
+ .k-stat__icon {
55
+ display: inline-flex;
56
+ align-items: center;
57
+ justify-content: center;
58
+ width: 1.75rem;
59
+ height: 1.75rem;
60
+ border-radius: var(--k-radius-sm);
61
+ background: var(--k-surface-muted);
62
+ color: var(--k-text-muted);
63
+ }
64
+ .k-stat__icon--primary { background: var(--k-primary-soft); color: var(--k-primary); }
65
+ .k-stat__icon--success { background: var(--k-success-soft); color: var(--k-success); }
66
+ .k-stat__icon--warning { background: var(--k-warning-soft); color: var(--k-warning); }
67
+ .k-stat__icon--danger { background: var(--k-danger-soft); color: var(--k-danger); }
68
+ .k-stat__value { margin-top: 0.35rem; font-size: 1.5rem; font-weight: 650; letter-spacing: -0.02em; }
69
+ .k-stat__foot { margin-top: 0.35rem; display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; }
70
+ .k-stat__trend { display: inline-flex; align-items: center; gap: 0.15rem; font-weight: 600; }
71
+ .k-stat__trend--good { color: var(--k-success); }
72
+ .k-stat__trend--bad { color: var(--k-danger); }
73
+ .k-stat__hint { color: var(--k-text-muted); }
74
+ </style>
@@ -0,0 +1,119 @@
1
+ <script setup>
2
+ /**
3
+ * KSteps — индикатор шагов мастера на @zag-js/steps.
4
+ *
5
+ * items: [{ label, hint? }] или строки
6
+ * current: индекс текущего шага (с нуля), контролируемый
7
+ * clickable: разрешить переход по клику на пройденные шаги (событие `select`)
8
+ */
9
+ import * as steps from '@zag-js/steps'
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
+ items: { type: Array, default: () => [] },
16
+ current: { type: Number, default: 0 },
17
+ clickable: { type: Boolean, default: false },
18
+ })
19
+
20
+ const emit = defineEmits(['select'])
21
+
22
+ const service = useMachine(steps.machine, {
23
+ id: useId(),
24
+ linear: false,
25
+ get step() {
26
+ return props.current
27
+ },
28
+ get count() {
29
+ return props.items.length
30
+ },
31
+ onStepChange({ step }) {
32
+ if (step !== props.current) emit('select', step)
33
+ },
34
+ })
35
+
36
+ const api = computed(() => steps.connect(service, normalizeProps))
37
+
38
+ const labelOf = (item) => (typeof item === 'string' ? item : item.label)
39
+ const hintOf = (item) => (typeof item === 'string' ? '' : item.hint)
40
+ </script>
41
+
42
+ <template>
43
+ <div class="k-steps" v-bind="api.getRootProps()">
44
+ <ol class="k-steps__list" v-bind="api.getListProps()">
45
+ <li v-for="(item, i) in items" :key="i" class="k-steps__item" v-bind="api.getItemProps({ index: i })">
46
+ <component
47
+ :is="clickable ? 'button' : 'div'"
48
+ class="k-steps__trigger"
49
+ :class="{ 'k-steps__trigger--clickable': clickable && i <= current }"
50
+ v-bind="clickable ? api.getTriggerProps({ index: i }) : {}"
51
+ :disabled="clickable && i > current ? true : null"
52
+ >
53
+ <span class="k-steps__marker" v-bind="api.getIndicatorProps({ index: i })">
54
+ <KIcon v-if="i < current" name="check" :size="14" />
55
+ <template v-else>{{ i + 1 }}</template>
56
+ </span>
57
+ <span class="k-steps__body">
58
+ <span class="k-steps__label">{{ labelOf(item) }}</span>
59
+ <span v-if="hintOf(item)" class="k-steps__hint">{{ hintOf(item) }}</span>
60
+ </span>
61
+ </component>
62
+ <span v-if="i < items.length - 1" class="k-steps__sep" v-bind="api.getSeparatorProps({ index: i })" />
63
+ </li>
64
+ </ol>
65
+ </div>
66
+ </template>
67
+
68
+ <style scoped>
69
+ .k-steps { overflow-x: auto; }
70
+ .k-steps__list {
71
+ display: flex;
72
+ align-items: center;
73
+ gap: 0.5rem;
74
+ margin: 0;
75
+ padding: 0;
76
+ list-style: none;
77
+ }
78
+ .k-steps__item { display: flex; align-items: center; gap: 0.5rem; white-space: nowrap; }
79
+ .k-steps__trigger {
80
+ display: flex;
81
+ align-items: center;
82
+ gap: 0.5rem;
83
+ padding: 0.375rem 0.5rem;
84
+ border: none;
85
+ border-radius: var(--k-radius-sm);
86
+ background: none;
87
+ font: inherit;
88
+ color: inherit;
89
+ text-align: left;
90
+ }
91
+ .k-steps__trigger--clickable { cursor: pointer; }
92
+ .k-steps__trigger--clickable:hover { background: var(--k-surface-muted); }
93
+ .k-steps__trigger:focus-visible { outline: 2px solid var(--k-primary); outline-offset: 2px; }
94
+ .k-steps__trigger:disabled { cursor: default; }
95
+ .k-steps__sep { width: 1.5rem; height: 1px; background: var(--k-border-strong); flex: none; }
96
+ .k-steps__marker {
97
+ display: inline-flex;
98
+ align-items: center;
99
+ justify-content: center;
100
+ width: 1.625rem;
101
+ height: 1.625rem;
102
+ border-radius: 50%;
103
+ background: var(--k-surface);
104
+ border: 1px solid var(--k-border-strong);
105
+ font-size: 0.8125rem;
106
+ font-weight: 600;
107
+ color: var(--k-text-muted);
108
+ flex: none;
109
+ }
110
+ .k-steps__body { display: flex; flex-direction: column; }
111
+ .k-steps__label { font-size: 0.875rem; color: var(--k-text-muted); }
112
+ .k-steps__hint { font-size: 0.6875rem; color: var(--k-text-faint); }
113
+ .k-steps__marker[data-complete] { background: var(--k-success-soft); border-color: currentColor; color: var(--k-success); }
114
+ .k-steps__trigger[data-complete] .k-steps__label { color: var(--k-text); }
115
+ .k-steps__marker[data-current] { background: var(--k-primary); border-color: var(--k-primary); color: var(--k-on-primary); }
116
+ .k-steps__trigger[data-current] .k-steps__label { color: var(--k-text); font-weight: 600; }
117
+ .k-steps__item[data-complete] .k-steps__label { color: var(--k-text); }
118
+ .k-steps__item[data-current] .k-steps__label { color: var(--k-text); font-weight: 600; }
119
+ </style>