@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,56 @@
1
+ <script setup>
2
+ /**
3
+ * KMenuItem — пункт меню внутри KDropdown. Рендерится как <router-link>,
4
+ * если передан `to`, иначе как кнопка. Атрибут data-k-menu-item нужен
5
+ * дропдауну, чтобы закрыться после выбора.
6
+ */
7
+ import KIcon from './KIcon.vue'
8
+
9
+ defineProps({
10
+ icon: { type: String, default: '' },
11
+ to: { type: [String, Object], default: null },
12
+ danger: { type: Boolean, default: false },
13
+ disabled: { type: Boolean, default: false },
14
+ })
15
+ </script>
16
+
17
+ <template>
18
+ <component
19
+ :is="to ? 'router-link' : 'button'"
20
+ :to="to"
21
+ :type="to ? null : 'button'"
22
+ class="k-menu-item"
23
+ :class="{ 'k-menu-item--danger': danger }"
24
+ :disabled="to ? null : disabled"
25
+ role="menuitem"
26
+ data-k-menu-item
27
+ >
28
+ <KIcon v-if="icon" :name="icon" :size="15" />
29
+ <span class="k-menu-item__label"><slot /></span>
30
+ </component>
31
+ </template>
32
+
33
+ <style scoped>
34
+ .k-menu-item {
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 0.5rem;
38
+ width: 100%;
39
+ padding: 0.45rem 0.55rem;
40
+ background: none;
41
+ border: none;
42
+ border-radius: var(--k-radius-sm);
43
+ font: inherit;
44
+ font-size: 0.875rem;
45
+ color: var(--k-text);
46
+ text-align: left;
47
+ text-decoration: none;
48
+ cursor: pointer;
49
+ }
50
+ .k-menu-item:hover { background: var(--k-surface-muted); text-decoration: none; }
51
+ .k-menu-item:focus-visible { outline: 2px solid var(--k-primary); outline-offset: -2px; }
52
+ .k-menu-item:disabled { opacity: 0.5; cursor: not-allowed; }
53
+ .k-menu-item--danger { color: var(--k-danger-strong); }
54
+ .k-menu-item--danger:hover { background: var(--k-danger-soft); }
55
+ .k-menu-item__label { flex: 1; min-width: 0; }
56
+ </style>
@@ -0,0 +1,136 @@
1
+ <script setup>
2
+ /**
3
+ * KModal — модальное окно на @zag-js/dialog: фокус-трап, блокировка скролла,
4
+ * закрытие по Esc и возврат фокуса. Открытие управляется v-model.
5
+ *
6
+ * Клик по фону по умолчанию не закрывает окно: случайный клик мимо формы не
7
+ * должен терять введённое. Включается пропом `close-on-outside`.
8
+ *
9
+ * <KModal v-model="open" title="Новый документ" size="md">
10
+ * <form @submit.prevent="save">…</form>
11
+ * <template #footer><KButton variant="primary" @click="save">Сохранить</KButton></template>
12
+ * </KModal>
13
+ */
14
+ import * as dialog from '@zag-js/dialog'
15
+ import { normalizeProps, useMachine } from '@zag-js/vue'
16
+ import { computed, useId } from 'vue'
17
+ import KIcon from './KIcon.vue'
18
+
19
+ const props = defineProps({
20
+ modelValue: { type: Boolean, default: false },
21
+ title: { type: String, default: '' },
22
+ subtitle: { type: String, default: '' },
23
+ size: { type: String, default: 'md' }, // sm | md | lg
24
+ closable: { type: Boolean, default: true },
25
+ closeOnOutside: { type: Boolean, default: false },
26
+ })
27
+
28
+ const emit = defineEmits(['update:modelValue', 'close'])
29
+
30
+ const service = useMachine(dialog.machine, {
31
+ id: useId(),
32
+ get open() {
33
+ return props.modelValue
34
+ },
35
+ get closeOnInteractOutside() {
36
+ return props.closeOnOutside && props.closable
37
+ },
38
+ get closeOnEscape() {
39
+ return props.closable
40
+ },
41
+ onOpenChange({ open }) {
42
+ emit('update:modelValue', open)
43
+ if (!open) emit('close')
44
+ },
45
+ })
46
+
47
+ const api = computed(() => dialog.connect(service, normalizeProps))
48
+ </script>
49
+
50
+ <template>
51
+ <Teleport to="body">
52
+ <template v-if="api.open">
53
+ <div class="k-modal__backdrop" v-bind="api.getBackdropProps()" />
54
+ <div class="k-modal__positioner" v-bind="api.getPositionerProps()">
55
+ <div class="k-modal__panel" :class="`k-modal__panel--${size}`" v-bind="api.getContentProps()">
56
+ <header v-if="title || $slots.header" class="k-modal__header">
57
+ <div class="k-modal__heading">
58
+ <slot name="header">
59
+ <h2 class="k-modal__title" v-bind="api.getTitleProps()">{{ title }}</h2>
60
+ <p v-if="subtitle" class="k-modal__subtitle" v-bind="api.getDescriptionProps()">{{ subtitle }}</p>
61
+ </slot>
62
+ </div>
63
+ <button v-if="closable" class="k-modal__close" aria-label="Закрыть" v-bind="api.getCloseTriggerProps()">
64
+ <KIcon name="close" :size="18" />
65
+ </button>
66
+ </header>
67
+ <div class="k-modal__body"><slot /></div>
68
+ <footer v-if="$slots.footer" class="k-modal__footer"><slot name="footer" /></footer>
69
+ </div>
70
+ </div>
71
+ </template>
72
+ </Teleport>
73
+ </template>
74
+
75
+ <style scoped>
76
+ .k-modal__backdrop {
77
+ position: fixed;
78
+ inset: 0;
79
+ z-index: 1000;
80
+ background: var(--k-overlay);
81
+ backdrop-filter: blur(2px);
82
+ }
83
+ .k-modal__positioner {
84
+ position: fixed;
85
+ inset: 0;
86
+ z-index: 1000;
87
+ display: flex;
88
+ align-items: center;
89
+ justify-content: center;
90
+ padding: 1rem;
91
+ }
92
+ .k-modal__panel {
93
+ width: min(92vw, var(--k-modal-w, 34rem));
94
+ max-height: 86vh;
95
+ display: flex;
96
+ flex-direction: column;
97
+ background: var(--k-surface);
98
+ border-radius: var(--k-radius-lg);
99
+ box-shadow: var(--k-shadow-lg);
100
+ overflow: hidden;
101
+ outline: none;
102
+ }
103
+ .k-modal__panel--sm { --k-modal-w: 24rem; }
104
+ .k-modal__panel--md { --k-modal-w: 34rem; }
105
+ .k-modal__panel--lg { --k-modal-w: 52rem; }
106
+ .k-modal__header {
107
+ display: flex;
108
+ align-items: flex-start;
109
+ justify-content: space-between;
110
+ gap: 1rem;
111
+ padding: 1.125rem 1.25rem;
112
+ border-bottom: 1px solid var(--k-border);
113
+ }
114
+ .k-modal__title { font-size: 1.0625rem; font-weight: 600; }
115
+ .k-modal__subtitle { margin-top: 0.15rem; font-size: 0.8125rem; color: var(--k-text-muted); }
116
+ .k-modal__close {
117
+ background: none;
118
+ border: none;
119
+ color: var(--k-text-muted);
120
+ cursor: pointer;
121
+ padding: 0.15rem;
122
+ border-radius: var(--k-radius-sm);
123
+ flex: none;
124
+ }
125
+ .k-modal__close:hover { background: var(--k-surface-muted); color: var(--k-text); }
126
+ .k-modal__body { padding: 1.25rem; overflow-y: auto; }
127
+ .k-modal__footer {
128
+ display: flex;
129
+ align-items: center;
130
+ justify-content: flex-end;
131
+ gap: 0.5rem;
132
+ padding: 0.875rem 1.25rem;
133
+ border-top: 1px solid var(--k-border);
134
+ background: var(--k-surface-muted);
135
+ }
136
+ </style>
@@ -0,0 +1,44 @@
1
+ <script setup>
2
+ /**
3
+ * KMoney — денежная сумма в русском формате.
4
+ *
5
+ * Вынесено в компонент, потому что суммы обычно встречаются на многих экранах
6
+ * и должны выглядеть одинаково: неразрывные разряды, символ валюты,
7
+ * опциональные копейки, табличные цифры для выравнивания в колонке.
8
+ */
9
+ import { computed } from 'vue'
10
+
11
+ const props = defineProps({
12
+ value: { type: [Number, String], default: 0 },
13
+ currency: { type: String, default: 'RUB' },
14
+ fraction: { type: Boolean, default: false },
15
+ compact: { type: Boolean, default: false },
16
+ tone: { type: String, default: '' }, // '' | muted | success | danger
17
+ size: { type: String, default: 'md' }, // sm | md | lg
18
+ })
19
+
20
+ const formatted = computed(() => {
21
+ const n = Number(props.value)
22
+ if (!Number.isFinite(n)) return '—'
23
+ return new Intl.NumberFormat('ru-RU', {
24
+ style: 'currency',
25
+ currency: props.currency,
26
+ minimumFractionDigits: props.fraction ? 2 : 0,
27
+ maximumFractionDigits: props.fraction ? 2 : 0,
28
+ notation: props.compact ? 'compact' : 'standard',
29
+ }).format(n)
30
+ })
31
+ </script>
32
+
33
+ <template>
34
+ <span class="k-money" :class="[tone && `k-money--${tone}`, `k-money--${size}`]">{{ formatted }}</span>
35
+ </template>
36
+
37
+ <style scoped>
38
+ .k-money { font-variant-numeric: tabular-nums; white-space: nowrap; }
39
+ .k-money--sm { font-size: 0.8125rem; }
40
+ .k-money--lg { font-size: 1.25rem; font-weight: 650; letter-spacing: -0.01em; }
41
+ .k-money--muted { color: var(--k-text-muted); }
42
+ .k-money--success { color: var(--k-success); }
43
+ .k-money--danger { color: var(--k-danger); }
44
+ </style>
@@ -0,0 +1,93 @@
1
+ <script setup>
2
+ /**
3
+ * KPage — стандартная обвязка экрана: хлебные крошки, заголовок, действия
4
+ * справа и контент. Используйте как корень каждого маршрута, чтобы отступы и
5
+ * расположение кнопок не разъезжались между страницами.
6
+ */
7
+ import KIcon from './KIcon.vue'
8
+
9
+ defineProps({
10
+ title: { type: String, default: '' },
11
+ subtitle: { type: String, default: '' },
12
+ // [{ label, to? }] — последний элемент отрисуется как текущий, без ссылки
13
+ breadcrumbs: { type: Array, default: () => [] },
14
+ back: { type: [String, Object], default: null },
15
+ width: { type: String, default: 'wide' }, // wide | narrow | full
16
+ })
17
+ </script>
18
+
19
+ <template>
20
+ <div class="k-page" :class="`k-page--${width}`">
21
+ <nav v-if="breadcrumbs.length" class="k-page__crumbs" aria-label="Хлебные крошки">
22
+ <template v-for="(crumb, i) in breadcrumbs" :key="i">
23
+ <component
24
+ :is="crumb.to && i < breadcrumbs.length - 1 ? 'router-link' : 'span'"
25
+ :to="crumb.to"
26
+ class="k-page__crumb"
27
+ :class="{ 'k-page__crumb--current': i === breadcrumbs.length - 1 }"
28
+ >{{ crumb.label }}</component>
29
+ <KIcon v-if="i < breadcrumbs.length - 1" name="chevron-right" :size="13" class="k-page__crumb-sep" />
30
+ </template>
31
+ </nav>
32
+
33
+ <header class="k-page__header">
34
+ <div class="k-page__heading">
35
+ <router-link v-if="back" :to="back" class="k-page__back" aria-label="Назад">
36
+ <KIcon name="back" :size="18" />
37
+ </router-link>
38
+ <div class="k-page__titles">
39
+ <div class="k-page__title-row">
40
+ <slot name="title"><h1>{{ title }}</h1></slot>
41
+ <slot name="title-extra" />
42
+ </div>
43
+ <p v-if="subtitle" class="k-page__subtitle">{{ subtitle }}</p>
44
+ </div>
45
+ </div>
46
+ <div v-if="$slots.actions" class="k-page__actions"><slot name="actions" /></div>
47
+ </header>
48
+
49
+ <slot />
50
+ </div>
51
+ </template>
52
+
53
+ <style scoped>
54
+ .k-page { padding: 1.75rem 2rem 3rem; margin: 0 auto; width: 100%; }
55
+ .k-page--wide { max-width: 1360px; }
56
+ .k-page--narrow { max-width: 880px; }
57
+ .k-page__crumbs { display: flex; align-items: center; gap: 0.3rem; margin-bottom: 0.6rem; font-size: 0.8125rem; }
58
+ .k-page__crumb { color: var(--k-text-muted); text-decoration: none; }
59
+ .k-page__crumb:hover { color: var(--k-primary); }
60
+ .k-page__crumb--current { color: var(--k-text); }
61
+ .k-page__crumb-sep { color: var(--k-text-faint); }
62
+ .k-page__header {
63
+ display: flex;
64
+ align-items: flex-start;
65
+ justify-content: space-between;
66
+ gap: 1rem;
67
+ margin-bottom: 1.5rem;
68
+ }
69
+ .k-page__heading { display: flex; align-items: flex-start; gap: 0.625rem; min-width: 0; }
70
+ .k-page__back {
71
+ display: inline-flex;
72
+ align-items: center;
73
+ justify-content: center;
74
+ width: 2rem;
75
+ height: 2rem;
76
+ margin-top: 0.15rem;
77
+ border: 1px solid var(--k-border);
78
+ border-radius: var(--k-radius-sm);
79
+ background: var(--k-surface);
80
+ color: var(--k-text-muted);
81
+ flex: none;
82
+ }
83
+ .k-page__back:hover { background: var(--k-surface-muted); color: var(--k-text); }
84
+ .k-page__titles { min-width: 0; }
85
+ .k-page__title-row { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
86
+ .k-page__subtitle { margin-top: 0.25rem; font-size: 0.875rem; color: var(--k-text-muted); }
87
+ .k-page__actions { display: flex; align-items: center; gap: 0.5rem; flex: none; flex-wrap: wrap; justify-content: flex-end; }
88
+
89
+ @media (max-width: 720px) {
90
+ .k-page { padding: 1.25rem 1rem 2.5rem; }
91
+ .k-page__header { flex-direction: column; align-items: stretch; }
92
+ }
93
+ </style>
@@ -0,0 +1,113 @@
1
+ <script setup>
2
+ /**
3
+ * KPagination — постраничная навигация на @zag-js/pagination.
4
+ *
5
+ * Контролируемая: компонент не хранит состояние, а сообщает нужную страницу
6
+ * через update:page. Диапазон страниц сворачивается многоточиями, чтобы ширина
7
+ * не зависела от объёма выборки.
8
+ *
9
+ * <KPagination v-model:page="page" :page-size="20" :total="total" />
10
+ */
11
+ import * as pagination from '@zag-js/pagination'
12
+ import { normalizeProps, useMachine } from '@zag-js/vue'
13
+ import { computed, useId } from 'vue'
14
+ import KIcon from './KIcon.vue'
15
+
16
+ const props = defineProps({
17
+ page: { type: Number, default: 1 },
18
+ pageSize: { type: Number, default: 20 },
19
+ total: { type: Number, default: 0 },
20
+ siblings: { type: Number, default: 1 },
21
+ showInfo: { type: Boolean, default: true },
22
+ })
23
+
24
+ const emit = defineEmits(['update:page'])
25
+
26
+ const service = useMachine(pagination.machine, {
27
+ id: useId(),
28
+ translations: {
29
+ rootLabel: 'Постраничная навигация',
30
+ prevTriggerLabel: 'Назад',
31
+ nextTriggerLabel: 'Вперёд',
32
+ itemLabel: ({ page }) => `Страница ${page}`,
33
+ },
34
+ get count() {
35
+ return props.total
36
+ },
37
+ get pageSize() {
38
+ return props.pageSize || 1
39
+ },
40
+ get siblingCount() {
41
+ return props.siblings
42
+ },
43
+ get page() {
44
+ return props.page
45
+ },
46
+ onPageChange({ page }) {
47
+ if (page !== props.page) emit('update:page', page)
48
+ },
49
+ })
50
+
51
+ const api = computed(() => pagination.connect(service, normalizeProps))
52
+
53
+ const rangeFrom = computed(() => (props.total ? api.value.pageRange.start + 1 : 0))
54
+ const rangeTo = computed(() => api.value.pageRange.end)
55
+ </script>
56
+
57
+ <template>
58
+ <nav class="k-pagination" v-bind="api.getRootProps()">
59
+ <span v-if="showInfo" class="k-pagination__info">{{ rangeFrom }}–{{ rangeTo }} из {{ total }}</span>
60
+ <div class="k-pagination__pages">
61
+ <button class="k-pagination__nav" v-bind="api.getPrevTriggerProps()">
62
+ <KIcon name="chevron-left" :size="16" />
63
+ </button>
64
+ <template v-for="(item, i) in api.pages" :key="item.type === 'page' ? item.value : `ellipsis-${i}`">
65
+ <span v-if="item.type === 'ellipsis'" class="k-pagination__gap" v-bind="api.getEllipsisProps({ index: i })">…</span>
66
+ <button v-else class="k-pagination__page" v-bind="api.getItemProps(item)">{{ item.value }}</button>
67
+ </template>
68
+ <button class="k-pagination__nav" v-bind="api.getNextTriggerProps()">
69
+ <KIcon name="chevron-right" :size="16" />
70
+ </button>
71
+ </div>
72
+ </nav>
73
+ </template>
74
+
75
+ <style scoped>
76
+ .k-pagination {
77
+ display: flex;
78
+ align-items: center;
79
+ justify-content: space-between;
80
+ gap: 1rem;
81
+ flex-wrap: wrap;
82
+ padding: 0.75rem 0.875rem;
83
+ border-top: 1px solid var(--k-border);
84
+ }
85
+ .k-pagination__info { font-size: 0.8125rem; color: var(--k-text-muted); }
86
+ .k-pagination__pages { display: flex; align-items: center; gap: 0.25rem; margin-left: auto; }
87
+ .k-pagination__page,
88
+ .k-pagination__nav {
89
+ min-width: var(--k-control-h-sm);
90
+ height: var(--k-control-h-sm);
91
+ padding: 0 0.4rem;
92
+ display: inline-flex;
93
+ align-items: center;
94
+ justify-content: center;
95
+ background: none;
96
+ border: 1px solid transparent;
97
+ border-radius: var(--k-radius-sm);
98
+ font: inherit;
99
+ font-size: 0.875rem;
100
+ color: var(--k-text-muted);
101
+ cursor: pointer;
102
+ }
103
+ .k-pagination__page:hover,
104
+ .k-pagination__nav:hover:not(:disabled) { background: var(--k-surface-muted); color: var(--k-text); }
105
+ .k-pagination__page[data-selected] {
106
+ background: var(--k-primary-soft);
107
+ border-color: currentColor;
108
+ color: var(--k-primary);
109
+ font-weight: 600;
110
+ }
111
+ .k-pagination__nav:disabled { opacity: 0.4; cursor: not-allowed; }
112
+ .k-pagination__gap { padding: 0 0.25rem; color: var(--k-text-faint); }
113
+ </style>
@@ -0,0 +1,63 @@
1
+ <script setup>
2
+ /**
3
+ * KProgress — линейный индикатор на @zag-js/progress: заполненность, ход
4
+ * операции, доля от нормы. `value: null` — неопределённое состояние.
5
+ */
6
+ import * as progress from '@zag-js/progress'
7
+ import { normalizeProps, useMachine } from '@zag-js/vue'
8
+ import { computed, useId } from 'vue'
9
+
10
+ const props = defineProps({
11
+ value: { type: Number, default: 0 },
12
+ max: { type: Number, default: 100 },
13
+ label: { type: String, default: '' },
14
+ tone: { type: String, default: 'primary' }, // primary | success | warning | danger
15
+ showValue: { type: Boolean, default: false },
16
+ })
17
+
18
+ const service = useMachine(progress.machine, {
19
+ id: useId(),
20
+ min: 0,
21
+ locale: 'ru-RU',
22
+ get value() {
23
+ return props.value
24
+ },
25
+ get max() {
26
+ return props.max
27
+ },
28
+ })
29
+
30
+ const api = computed(() => progress.connect(service, normalizeProps))
31
+ </script>
32
+
33
+ <template>
34
+ <div class="k-progress" v-bind="api.getRootProps()">
35
+ <div v-if="label || showValue" class="k-progress__head">
36
+ <span class="k-progress__label" v-bind="api.getLabelProps()">{{ label }}</span>
37
+ <span v-if="showValue" class="k-progress__value" v-bind="api.getValueTextProps()">{{ api.percentAsString }}</span>
38
+ </div>
39
+ <div class="k-progress__track" v-bind="api.getTrackProps()">
40
+ <div class="k-progress__bar" :class="`k-progress__bar--${tone}`" v-bind="api.getRangeProps()" />
41
+ </div>
42
+ </div>
43
+ </template>
44
+
45
+ <style scoped>
46
+ .k-progress__head { display: flex; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.3rem; font-size: 0.8125rem; }
47
+ .k-progress__label { color: var(--k-text-muted); }
48
+ .k-progress__value { font-weight: 600; font-variant-numeric: tabular-nums; }
49
+ .k-progress__track { height: 0.5rem; background: var(--k-surface-muted); border-radius: 999px; overflow: hidden; }
50
+ .k-progress__bar { height: 100%; border-radius: 999px; transition: width 0.3s ease; }
51
+ .k-progress__bar[data-state="indeterminate"] {
52
+ width: 40%;
53
+ animation: k-progress-slide 1.2s ease-in-out infinite;
54
+ }
55
+ .k-progress__bar--primary { background: var(--k-primary); }
56
+ .k-progress__bar--success { background: var(--k-success); }
57
+ .k-progress__bar--warning { background: var(--k-warning); }
58
+ .k-progress__bar--danger { background: var(--k-danger); }
59
+ @keyframes k-progress-slide {
60
+ from { transform: translateX(-100%); }
61
+ to { transform: translateX(250%); }
62
+ }
63
+ </style>
@@ -0,0 +1,103 @@
1
+ <script setup>
2
+ /**
3
+ * KRadioGroup — группа радио-кнопок на @zag-js/radio-group: навигация
4
+ * стрелками и roving tabindex от машины. Опции — строки или
5
+ * { value, label, hint?, disabled? }. Направление — колонка или строка.
6
+ */
7
+ import * as radio from '@zag-js/radio-group'
8
+ import { normalizeProps, useMachine } from '@zag-js/vue'
9
+ import { computed, useId } from 'vue'
10
+
11
+ const props = defineProps({
12
+ modelValue: { type: [String, Number, Boolean, null], default: null },
13
+ options: { type: Array, default: () => [] },
14
+ label: { type: String, default: '' },
15
+ name: { type: String, default: '' },
16
+ inline: { type: Boolean, default: false },
17
+ disabled: { type: Boolean, default: false },
18
+ })
19
+
20
+ const emit = defineEmits(['update:modelValue'])
21
+
22
+ const items = computed(() =>
23
+ props.options.map((o) => (typeof o === 'object' && o !== null ? o : { value: o, label: String(o) })),
24
+ )
25
+
26
+ const service = useMachine(radio.machine, {
27
+ id: useId(),
28
+ get value() {
29
+ return props.modelValue == null ? null : String(props.modelValue)
30
+ },
31
+ get disabled() {
32
+ return props.disabled
33
+ },
34
+ get name() {
35
+ return props.name || undefined
36
+ },
37
+ get orientation() {
38
+ return props.inline ? 'horizontal' : 'vertical'
39
+ },
40
+ onValueChange({ value }) {
41
+ const item = items.value.find((i) => String(i.value) === value)
42
+ emit('update:modelValue', item ? item.value : value)
43
+ },
44
+ })
45
+
46
+ const api = computed(() => radio.connect(service, normalizeProps))
47
+
48
+ const itemProps = (item) => ({ value: String(item.value), disabled: item.disabled })
49
+ </script>
50
+
51
+ <template>
52
+ <div class="k-radio" v-bind="api.getRootProps()">
53
+ <span v-if="label" class="k-radio__legend" v-bind="api.getLabelProps()">{{ label }}</span>
54
+ <div class="k-radio__items" :class="{ 'k-radio__items--inline': inline }">
55
+ <label
56
+ v-for="item in items"
57
+ :key="item.value"
58
+ class="k-radio__item"
59
+ v-bind="api.getItemProps(itemProps(item))"
60
+ >
61
+ <input v-bind="api.getItemHiddenInputProps(itemProps(item))" />
62
+ <span class="k-radio__control" v-bind="api.getItemControlProps(itemProps(item))" />
63
+ <span class="k-radio__body">
64
+ <span class="k-radio__label" v-bind="api.getItemTextProps(itemProps(item))">{{ item.label }}</span>
65
+ <span v-if="item.hint" class="k-radio__hint">{{ item.hint }}</span>
66
+ </span>
67
+ </label>
68
+ </div>
69
+ </div>
70
+ </template>
71
+
72
+ <style scoped>
73
+ .k-radio { min-width: 0; }
74
+ .k-radio__legend { display: block; padding-bottom: 0.375rem; font-size: 0.8125rem; color: var(--k-text-muted); }
75
+ .k-radio__items { display: flex; flex-direction: column; gap: 0.5rem; }
76
+ .k-radio__items--inline { flex-direction: row; flex-wrap: wrap; gap: 1rem; }
77
+ .k-radio__item { display: inline-flex; align-items: flex-start; gap: 0.5rem; cursor: pointer; }
78
+ .k-radio__item[data-disabled] { cursor: not-allowed; opacity: 0.6; }
79
+ .k-radio__control {
80
+ position: relative;
81
+ width: 1.05rem;
82
+ height: 1.05rem;
83
+ margin-top: 0.15rem;
84
+ flex: none;
85
+ background: var(--k-surface);
86
+ border: 1px solid var(--k-border-strong);
87
+ border-radius: 50%;
88
+ transition: border-color 0.12s, box-shadow 0.12s;
89
+ }
90
+ .k-radio__control[data-hover]:not([data-disabled]) { border-color: var(--k-primary); }
91
+ .k-radio__control[data-focus-visible] { box-shadow: 0 0 0 3px var(--k-primary-soft); border-color: var(--k-primary); }
92
+ .k-radio__control[data-state="checked"] { border-color: var(--k-primary); }
93
+ .k-radio__control[data-state="checked"]::after {
94
+ content: '';
95
+ position: absolute;
96
+ inset: 3px;
97
+ border-radius: 50%;
98
+ background: var(--k-primary);
99
+ }
100
+ .k-radio__body { display: flex; flex-direction: column; gap: 0.1rem; }
101
+ .k-radio__label { font-size: 0.9375rem; }
102
+ .k-radio__hint { font-size: 0.75rem; color: var(--k-text-muted); }
103
+ </style>