@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,91 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KFilterBar — панель поиска и фильтров над списком.
|
|
4
|
+
*
|
|
5
|
+
* Слоты: #search (обычно KInput), по умолчанию — сами фильтры, #actions —
|
|
6
|
+
* кнопки справа. Ниже показываются активные фильтры чипами со сбросом: без
|
|
7
|
+
* этого пользователь теряет из виду, почему список пуст.
|
|
8
|
+
*
|
|
9
|
+
* active: [{ key, label }]
|
|
10
|
+
*/
|
|
11
|
+
import KIcon from './KIcon.vue'
|
|
12
|
+
|
|
13
|
+
defineProps({
|
|
14
|
+
active: { type: Array, default: () => [] },
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
defineEmits(['remove', 'reset'])
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<div class="k-filterbar">
|
|
22
|
+
<div class="k-filterbar__row">
|
|
23
|
+
<div v-if="$slots.search" class="k-filterbar__search"><slot name="search" /></div>
|
|
24
|
+
<div class="k-filterbar__fields"><slot /></div>
|
|
25
|
+
<div v-if="$slots.actions" class="k-filterbar__actions"><slot name="actions" /></div>
|
|
26
|
+
</div>
|
|
27
|
+
<div v-if="active.length" class="k-filterbar__chips">
|
|
28
|
+
<span v-for="chip in active" :key="chip.key" class="k-filterbar__chip">
|
|
29
|
+
{{ chip.label }}
|
|
30
|
+
<button class="k-filterbar__chip-x" aria-label="Убрать фильтр" @click="$emit('remove', chip)">
|
|
31
|
+
<KIcon name="close" :size="12" />
|
|
32
|
+
</button>
|
|
33
|
+
</span>
|
|
34
|
+
<button class="k-filterbar__reset" @click="$emit('reset')">Сбросить всё</button>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|
|
38
|
+
|
|
39
|
+
<style scoped>
|
|
40
|
+
.k-filterbar {
|
|
41
|
+
background: var(--k-surface);
|
|
42
|
+
border: 1px solid var(--k-border);
|
|
43
|
+
border-radius: var(--k-radius);
|
|
44
|
+
box-shadow: var(--k-shadow-sm);
|
|
45
|
+
padding: 0.875rem 1rem;
|
|
46
|
+
}
|
|
47
|
+
.k-filterbar__row { display: flex; align-items: flex-end; gap: 0.75rem; flex-wrap: wrap; }
|
|
48
|
+
.k-filterbar__search { flex: 1 1 20rem; min-width: 14rem; }
|
|
49
|
+
.k-filterbar__fields { display: flex; align-items: flex-end; gap: 0.75rem; flex-wrap: wrap; }
|
|
50
|
+
.k-filterbar__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }
|
|
51
|
+
.k-filterbar__chips {
|
|
52
|
+
display: flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
gap: 0.4rem;
|
|
55
|
+
flex-wrap: wrap;
|
|
56
|
+
margin-top: 0.75rem;
|
|
57
|
+
padding-top: 0.75rem;
|
|
58
|
+
border-top: 1px dashed var(--k-border);
|
|
59
|
+
}
|
|
60
|
+
.k-filterbar__chip {
|
|
61
|
+
display: inline-flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
gap: 0.3rem;
|
|
64
|
+
padding: 0.15rem 0.3rem 0.15rem 0.55rem;
|
|
65
|
+
background: var(--k-primary-soft);
|
|
66
|
+
color: var(--k-primary);
|
|
67
|
+
border-radius: 999px;
|
|
68
|
+
font-size: 0.75rem;
|
|
69
|
+
font-weight: 500;
|
|
70
|
+
}
|
|
71
|
+
.k-filterbar__chip-x {
|
|
72
|
+
display: inline-flex;
|
|
73
|
+
background: none;
|
|
74
|
+
border: none;
|
|
75
|
+
color: inherit;
|
|
76
|
+
opacity: 0.65;
|
|
77
|
+
cursor: pointer;
|
|
78
|
+
padding: 0.1rem;
|
|
79
|
+
}
|
|
80
|
+
.k-filterbar__chip-x:hover { opacity: 1; }
|
|
81
|
+
.k-filterbar__reset {
|
|
82
|
+
background: none;
|
|
83
|
+
border: none;
|
|
84
|
+
font: inherit;
|
|
85
|
+
font-size: 0.75rem;
|
|
86
|
+
color: var(--k-text-muted);
|
|
87
|
+
cursor: pointer;
|
|
88
|
+
text-decoration: underline;
|
|
89
|
+
}
|
|
90
|
+
.k-filterbar__reset:hover { color: var(--k-text); }
|
|
91
|
+
</style>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KIcon — единственная точка входа для иконок кита.
|
|
4
|
+
*
|
|
5
|
+
* Оборачивает Lucide (https://lucide.dev, ISC). Компоненты и приложения
|
|
6
|
+
* ссылаются на иконку по *семантическому* имени ("delete", "settings"), а не
|
|
7
|
+
* по форме, поэтому набор можно заменить в одном месте — см. icon-registry.js.
|
|
8
|
+
*
|
|
9
|
+
* <KIcon name="settings" :size="16" />
|
|
10
|
+
*
|
|
11
|
+
* Иконку, которой нет в реестре, можно передать напрямую компонентом —
|
|
12
|
+
* так приложение расширяет набор, не форкая кит:
|
|
13
|
+
*
|
|
14
|
+
* <KIcon :component="MyBrandIcon" />
|
|
15
|
+
*/
|
|
16
|
+
import { computed } from 'vue'
|
|
17
|
+
import { iconRegistry } from './icon-registry.js'
|
|
18
|
+
|
|
19
|
+
const props = defineProps({
|
|
20
|
+
name: { type: String, default: '' },
|
|
21
|
+
component: { type: [Object, Function], default: null },
|
|
22
|
+
size: { type: [Number, String], default: 18 },
|
|
23
|
+
strokeWidth: { type: [Number, String], default: 2 },
|
|
24
|
+
spin: { type: Boolean, default: false },
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const resolved = computed(() => {
|
|
28
|
+
if (props.component) return props.component
|
|
29
|
+
const c = iconRegistry[props.name]
|
|
30
|
+
if (!c && props.name && import.meta.env?.DEV) {
|
|
31
|
+
console.warn(`[KIcon] неизвестная иконка "${props.name}" — добавьте её в icon-registry.js или передайте :component`)
|
|
32
|
+
}
|
|
33
|
+
return c || null
|
|
34
|
+
})
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<component
|
|
39
|
+
:is="resolved"
|
|
40
|
+
v-if="resolved"
|
|
41
|
+
class="k-icon"
|
|
42
|
+
:class="{ 'k-icon--spin': spin }"
|
|
43
|
+
:size="Number(size)"
|
|
44
|
+
:stroke-width="Number(strokeWidth)"
|
|
45
|
+
aria-hidden="true"
|
|
46
|
+
/>
|
|
47
|
+
</template>
|
|
48
|
+
|
|
49
|
+
<style scoped>
|
|
50
|
+
.k-icon { display: inline-block; vertical-align: -0.15em; flex-shrink: 0; }
|
|
51
|
+
.k-icon--spin { animation: k-icon-spin 1s linear infinite; }
|
|
52
|
+
@keyframes k-icon-spin { to { transform: rotate(360deg); } }
|
|
53
|
+
</style>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KInput — однострочное поле ввода с подписью, подсказкой и ошибкой.
|
|
4
|
+
*
|
|
5
|
+
* Метрики совпадают с KSelect и KButton, поэтому строка фильтров
|
|
6
|
+
* выравнивается без правок в приложении. Нативные атрибуты (autocomplete,
|
|
7
|
+
* inputmode, min, @blur, …) прокидываются на <input>.
|
|
8
|
+
*
|
|
9
|
+
* <KInput v-model="query" label="Поиск" prefix-icon="search" :error="err" />
|
|
10
|
+
*/
|
|
11
|
+
import { computed, useAttrs } from 'vue'
|
|
12
|
+
import KIcon from './KIcon.vue'
|
|
13
|
+
|
|
14
|
+
defineOptions({ inheritAttrs: false })
|
|
15
|
+
|
|
16
|
+
/*
|
|
17
|
+
* Атрибуты разделяются: class и style остаются на корне (иначе layout-классы
|
|
18
|
+
* из родителя не могут задать ширину поля в строке фильтров), остальное —
|
|
19
|
+
* autocomplete, inputmode, min, обработчики — уходит на сам контрол.
|
|
20
|
+
*/
|
|
21
|
+
const attrs = useAttrs()
|
|
22
|
+
const rootAttrs = computed(() => ({ class: attrs.class, style: attrs.style }))
|
|
23
|
+
const controlAttrs = computed(() => {
|
|
24
|
+
const { class: _c, style: _s, ...rest } = attrs
|
|
25
|
+
return rest
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
defineProps({
|
|
29
|
+
modelValue: { type: [String, Number], default: '' },
|
|
30
|
+
label: { type: String, default: '' },
|
|
31
|
+
placeholder: { type: String, default: '' },
|
|
32
|
+
type: { type: String, default: 'text' },
|
|
33
|
+
hint: { type: String, default: '' },
|
|
34
|
+
error: { type: String, default: '' },
|
|
35
|
+
prefixIcon: { type: String, default: '' },
|
|
36
|
+
suffix: { type: String, default: '' },
|
|
37
|
+
required: { type: Boolean, default: false },
|
|
38
|
+
disabled: { type: Boolean, default: false },
|
|
39
|
+
readonly: { type: Boolean, default: false },
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
defineEmits(['update:modelValue'])
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<template>
|
|
46
|
+
<label class="k-input" v-bind="rootAttrs">
|
|
47
|
+
<span v-if="label" class="k-input__label">
|
|
48
|
+
{{ label }}<span v-if="required" class="k-input__req">*</span>
|
|
49
|
+
</span>
|
|
50
|
+
<span
|
|
51
|
+
class="k-input__box"
|
|
52
|
+
:class="{ 'k-input__box--error': error, 'k-input__box--disabled': disabled }"
|
|
53
|
+
>
|
|
54
|
+
<KIcon v-if="prefixIcon" :name="prefixIcon" :size="16" class="k-input__icon" />
|
|
55
|
+
<input
|
|
56
|
+
class="k-input__field"
|
|
57
|
+
:type="type"
|
|
58
|
+
:value="modelValue"
|
|
59
|
+
:placeholder="placeholder"
|
|
60
|
+
:required="required"
|
|
61
|
+
:disabled="disabled"
|
|
62
|
+
:readonly="readonly"
|
|
63
|
+
:aria-invalid="error ? 'true' : null"
|
|
64
|
+
v-bind="controlAttrs"
|
|
65
|
+
@input="$emit('update:modelValue', $event.target.value)"
|
|
66
|
+
/>
|
|
67
|
+
<span v-if="suffix" class="k-input__suffix">{{ suffix }}</span>
|
|
68
|
+
</span>
|
|
69
|
+
<span v-if="error" class="k-input__error">{{ error }}</span>
|
|
70
|
+
<span v-else-if="hint" class="k-input__hint">{{ hint }}</span>
|
|
71
|
+
</label>
|
|
72
|
+
</template>
|
|
73
|
+
|
|
74
|
+
<style scoped>
|
|
75
|
+
.k-input { display: flex; flex-direction: column; gap: 0.25rem; margin: 0; min-width: 0; }
|
|
76
|
+
.k-input__label { font-size: 0.8125rem; color: var(--k-text-muted); }
|
|
77
|
+
.k-input__req { color: var(--k-danger); margin-left: 2px; }
|
|
78
|
+
.k-input__box {
|
|
79
|
+
display: flex;
|
|
80
|
+
align-items: center;
|
|
81
|
+
gap: 0.5rem;
|
|
82
|
+
height: var(--k-control-h);
|
|
83
|
+
padding: 0 0.625rem;
|
|
84
|
+
background: var(--k-surface);
|
|
85
|
+
border: 1px solid var(--k-border-strong);
|
|
86
|
+
border-radius: var(--k-radius-sm);
|
|
87
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
88
|
+
}
|
|
89
|
+
.k-input__box:focus-within { border-color: var(--k-primary); box-shadow: 0 0 0 3px var(--k-primary-soft); }
|
|
90
|
+
.k-input__box--error { border-color: var(--k-danger); }
|
|
91
|
+
.k-input__box--error:focus-within { box-shadow: 0 0 0 3px var(--k-danger-soft); }
|
|
92
|
+
.k-input__box--disabled { background: var(--k-surface-muted); }
|
|
93
|
+
.k-input__icon { color: var(--k-text-faint); }
|
|
94
|
+
.k-input__field {
|
|
95
|
+
flex: 1;
|
|
96
|
+
min-width: 0;
|
|
97
|
+
border: none;
|
|
98
|
+
background: none;
|
|
99
|
+
outline: none;
|
|
100
|
+
font: inherit;
|
|
101
|
+
font-size: 0.9375rem;
|
|
102
|
+
color: inherit;
|
|
103
|
+
}
|
|
104
|
+
.k-input__field:disabled { cursor: not-allowed; color: var(--k-text-muted); }
|
|
105
|
+
.k-input__suffix { font-size: 0.875rem; color: var(--k-text-muted); white-space: nowrap; }
|
|
106
|
+
.k-input__hint { font-size: 0.75rem; color: var(--k-text-muted); }
|
|
107
|
+
.k-input__error { font-size: 0.75rem; color: var(--k-danger); }
|
|
108
|
+
</style>
|
|
@@ -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>
|