@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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +228 -2
  3. package/dist/ui-kit.css +2 -0
  4. package/dist/ui-kit.js +14955 -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/KIcon.vue +53 -0
  22. package/src/components/KInput.vue +108 -0
  23. package/src/components/KMenuItem.vue +56 -0
  24. package/src/components/KModal.vue +136 -0
  25. package/src/components/KMoney.vue +44 -0
  26. package/src/components/KPage.vue +93 -0
  27. package/src/components/KPagination.vue +113 -0
  28. package/src/components/KProgress.vue +63 -0
  29. package/src/components/KRadioGroup.vue +103 -0
  30. package/src/components/KSegmented.vue +88 -0
  31. package/src/components/KSelect.vue +231 -0
  32. package/src/components/KShell.vue +176 -0
  33. package/src/components/KStat.vue +74 -0
  34. package/src/components/KSteps.vue +119 -0
  35. package/src/components/KSwitch.vue +82 -0
  36. package/src/components/KTable.vue +161 -0
  37. package/src/components/KTabs.vue +102 -0
  38. package/src/components/KTextarea.vue +79 -0
  39. package/src/components/KTimeline.vue +72 -0
  40. package/src/components/KToast.vue +34 -0
  41. package/src/components/KToastItem.vue +86 -0
  42. package/src/components/KTooltip.vue +67 -0
  43. package/src/components/icon-registry.js +143 -0
  44. package/src/composables/useToast.js +52 -0
  45. package/src/format.js +137 -0
  46. package/src/index.js +84 -0
  47. package/src/theme.js +417 -0
  48. 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>