@krometra/ui-kit 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +228 -2
- package/dist/ui-kit.css +2 -0
- package/dist/ui-kit.js +14955 -0
- package/dist/ui-kit.umd.cjs +3 -0
- package/package.json +74 -3
- package/src/base.css +32 -0
- package/src/components/KAlert.vue +65 -0
- package/src/components/KAvatar.vue +67 -0
- package/src/components/KBadge.vue +47 -0
- package/src/components/KButton.vue +98 -0
- package/src/components/KCard.vue +63 -0
- package/src/components/KCheckbox.vue +105 -0
- package/src/components/KCombobox.vue +232 -0
- package/src/components/KCountdown.vue +74 -0
- package/src/components/KDescriptions.vue +40 -0
- package/src/components/KDropdown.vue +111 -0
- package/src/components/KEmpty.vue +39 -0
- package/src/components/KFileUpload.vue +121 -0
- package/src/components/KFilterBar.vue +91 -0
- package/src/components/KIcon.vue +53 -0
- package/src/components/KInput.vue +108 -0
- package/src/components/KMenuItem.vue +56 -0
- package/src/components/KModal.vue +136 -0
- package/src/components/KMoney.vue +44 -0
- package/src/components/KPage.vue +93 -0
- package/src/components/KPagination.vue +113 -0
- package/src/components/KProgress.vue +63 -0
- package/src/components/KRadioGroup.vue +103 -0
- package/src/components/KSegmented.vue +88 -0
- package/src/components/KSelect.vue +231 -0
- package/src/components/KShell.vue +176 -0
- package/src/components/KStat.vue +74 -0
- package/src/components/KSteps.vue +119 -0
- package/src/components/KSwitch.vue +82 -0
- package/src/components/KTable.vue +161 -0
- package/src/components/KTabs.vue +102 -0
- package/src/components/KTextarea.vue +79 -0
- package/src/components/KTimeline.vue +72 -0
- package/src/components/KToast.vue +34 -0
- package/src/components/KToastItem.vue +86 -0
- package/src/components/KTooltip.vue +67 -0
- package/src/components/icon-registry.js +143 -0
- package/src/composables/useToast.js +52 -0
- package/src/format.js +137 -0
- package/src/index.js +84 -0
- package/src/theme.js +417 -0
- package/src/tokens.css +109 -0
|
@@ -0,0 +1,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>
|
|
@@ -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>
|