@milaboratories/uikit 2.2.3 → 2.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milaboratories/uikit",
3
- "version": "2.2.3",
3
+ "version": "2.2.4",
4
4
  "type": "module",
5
5
  "main": "dist/pl-uikit.umd.js",
6
6
  "module": "dist/pl-uikit.js",
@@ -32,8 +32,8 @@
32
32
  "vue-tsc": "^2.1.6",
33
33
  "yarpm": "^1.2.0",
34
34
  "svgo": "^3.3.2",
35
- "@milaboratories/helpers": "^1.6.6",
36
- "@platforma-sdk/model": "^1.8.19"
35
+ "@platforma-sdk/model": "^1.10.2",
36
+ "@milaboratories/helpers": "^1.6.6"
37
37
  },
38
38
  "scripts": {
39
39
  "dev": "vite",
@@ -168,4 +168,4 @@ body {
168
168
  display: flex;
169
169
  flex-direction: column;
170
170
  gap: 24px;
171
- }
171
+ }
@@ -4,7 +4,6 @@
4
4
  padding: 8px 12px;
5
5
  display: flex;
6
6
  align-items: center;
7
- // flex-direction: column;
8
7
  min-height: 40px;
9
8
 
10
9
  &__small {
@@ -13,9 +12,7 @@
13
12
  }
14
13
 
15
14
  &__title-container {
16
- // height: 24px;
17
15
  display: flex;
18
- // align-items: center;
19
16
  margin-right: 8px;
20
17
  flex-grow: 1;
21
18
  flex-direction: column;
@@ -39,10 +36,14 @@
39
36
  }
40
37
 
41
38
  &:hover {
42
- background-color: var(--bg-elevated-02);
39
+ background-color: var(--btn-sec-hover-grey);
43
40
  cursor: pointer;
44
41
  }
45
42
 
43
+ &:active {
44
+ background-color: var(--btn-sec-press-grey);
45
+ }
46
+
46
47
  &__selected {
47
48
  background-color: var(--color-active-select) !important;
48
49
  }
@@ -1,10 +1,9 @@
1
1
  h1 {
2
- /* Title/H1 */
3
2
  font-family: var(--font-family-base);
4
3
  font-size: 64px;
5
4
  font-style: normal;
6
5
  font-weight: 400;
7
- line-height: 72px; /* 112.5% */
6
+ line-height: 72px;
8
7
  letter-spacing: -1.28px;
9
8
  margin: 0;
10
9
  }
@@ -14,7 +13,7 @@ h2 {
14
13
  font-size: 40px;
15
14
  font-style: normal;
16
15
  font-weight: 500;
17
- line-height: 48px; /* 120% */
16
+ line-height: 48px;
18
17
  letter-spacing: -0.8px;
19
18
  margin: 0;
20
19
  }
@@ -24,18 +23,19 @@ h3 {
24
23
  font-size: 28px;
25
24
  font-style: normal;
26
25
  font-weight: 500;
27
- line-height: 32px; /* 114.286% */
26
+ line-height: 32px;
28
27
  letter-spacing: -0.56px;
29
28
  margin: 0;
30
29
  }
31
30
 
32
- h4, h5, h6 {
33
- /* Title/Subtitle S */
31
+ h4,
32
+ h5,
33
+ h6 {
34
34
  font-family: var(--font-family-base);
35
35
  font-size: 20px;
36
36
  font-style: normal;
37
37
  font-weight: 500;
38
- line-height: 24px; /* 120% */
38
+ line-height: 24px;
39
39
  letter-spacing: -0.2px;
40
40
  margin: 0;
41
41
  }
@@ -73,7 +73,6 @@ mark {
73
73
 
74
74
  code {
75
75
  font-feature-settings: 'ss11' on, 'ss15' on, 'ss17' on;
76
- /* Text/Monospace M */
77
76
  font-family: var(--font-family-monospace);
78
77
  font-size: 14px;
79
78
  font-style: normal;
@@ -185,6 +184,7 @@ blockquote {
185
184
  font-style: normal;
186
185
  font-weight: 600;
187
186
  line-height: 20px;
187
+ text-transform: capitalize;
188
188
  }
189
189
 
190
190
  .text-s-link {
@@ -252,4 +252,4 @@ blockquote {
252
252
  font-style: normal;
253
253
  font-weight: 500;
254
254
  line-height: 16px;
255
- }
255
+ }
@@ -0,0 +1,201 @@
1
+ <script setup lang="ts" generic="M = unknown">
2
+ import { computed, reactive, ref, unref, watch } from 'vue';
3
+ import './pl-btn-split.scss';
4
+ import DropdownListItem from '../DropdownListItem.vue';
5
+ import type { ListOption } from '@/types';
6
+ import { useElementPosition } from '@/composition/usePosition';
7
+ import { normalizeListOptions } from '@/helpers/utils';
8
+ import { deepEqual } from '@milaboratories/helpers';
9
+ import PlMaskIcon16 from '../PlMaskIcon16/PlMaskIcon16.vue';
10
+
11
+ const props = defineProps<{
12
+ /**
13
+ * List of available options for the dropdown menu
14
+ */
15
+ options?: Readonly<ListOption<M>[]>;
16
+
17
+ /**
18
+ * If `true`, the dropdown component is disabled and cannot be interacted with.
19
+ */
20
+ disabled?: boolean;
21
+
22
+ /**
23
+ * If `true,` the button is disabled, cannot be interacted with, and shows a special 'loading' icon.
24
+ */
25
+ loading?: boolean;
26
+ }>();
27
+ const emits = defineEmits(['click']);
28
+
29
+ const model = defineModel<M>({ required: true });
30
+
31
+ const root = ref<HTMLElement | undefined>();
32
+ const list = ref<HTMLElement | undefined>();
33
+ const menuActivator = ref<HTMLElement | undefined>();
34
+ const buttonAction = ref<HTMLElement | undefined>();
35
+
36
+ const data = reactive({
37
+ open: false,
38
+ optionsHeight: 0,
39
+ activeIndex: -1,
40
+ });
41
+
42
+ defineExpose({
43
+ data,
44
+ });
45
+
46
+ const optionsStyle = reactive({
47
+ top: '0px',
48
+ left: '0px',
49
+ width: '0px',
50
+ });
51
+
52
+ watch(
53
+ list,
54
+ (el) => {
55
+ if (el) {
56
+ const rect = el.getBoundingClientRect();
57
+ data.optionsHeight = rect.height;
58
+ window.dispatchEvent(new CustomEvent('adjust'));
59
+ }
60
+ },
61
+ { immediate: true },
62
+ );
63
+
64
+ const iconState = computed(() => (data.open ? 'mask-24 mask-chevron-up' : 'mask-24 mask-chevron-down'));
65
+
66
+ const selectedIndex = computed(() => {
67
+ return (props.options ?? []).findIndex((o) => deepEqual(o.value, model.value));
68
+ });
69
+
70
+ const items = computed(() =>
71
+ normalizeListOptions(props.options ?? []).map((opt, index) => ({
72
+ ...opt,
73
+ index,
74
+ isSelected: index === selectedIndex.value,
75
+ isActive: index === data.activeIndex,
76
+ })),
77
+ );
78
+
79
+ const isLoadingOptions = computed(() => props.loading || props.options === undefined);
80
+
81
+ const actionName = computed(() => items.value.find((o) => deepEqual(o.value, model.value))?.label ?? (props.options === undefined ? '...' : ''));
82
+
83
+ useElementPosition(root, (pos) => {
84
+ const focusWidth = 3;
85
+
86
+ const downTopOffset = pos.top + pos.height + focusWidth;
87
+
88
+ if (downTopOffset + data.optionsHeight > pos.clientHeight) {
89
+ optionsStyle.top = pos.top - data.optionsHeight - focusWidth + 'px';
90
+ } else {
91
+ optionsStyle.top = downTopOffset + 'px';
92
+ }
93
+
94
+ optionsStyle.left = pos.left + 'px';
95
+ optionsStyle.width = pos.width + 'px';
96
+
97
+ console.log(pos.top, optionsStyle);
98
+ });
99
+
100
+ const selectOption = (v: M | undefined) => {
101
+ model.value = v!;
102
+ data.open = false;
103
+ root?.value?.focus();
104
+ };
105
+
106
+ function emitEnter() {
107
+ emits('click');
108
+ }
109
+
110
+ const handleKeydown = (e: { code: string; preventDefault(): void; stopPropagation(): void; target: EventTarget | null }) => {
111
+ if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(e.code)) {
112
+ return;
113
+ } else {
114
+ e.preventDefault();
115
+ }
116
+
117
+ if (e.target === buttonAction.value && e.code === 'Enter') {
118
+ emitEnter();
119
+ return;
120
+ }
121
+
122
+ const { open, activeIndex } = data;
123
+
124
+ if (!open && e.target === menuActivator.value) {
125
+ if (e.code === 'Enter') {
126
+ data.open = true;
127
+ }
128
+ return;
129
+ }
130
+
131
+ if (e.code === 'Escape') {
132
+ data.open = false;
133
+ root.value?.focus();
134
+ }
135
+
136
+ const filtered = unref(items);
137
+
138
+ const { length } = filtered;
139
+
140
+ if (!length) {
141
+ return;
142
+ }
143
+
144
+ if (e.code === 'Enter') {
145
+ selectOption(filtered.find((it) => it.index === activeIndex)?.value);
146
+ }
147
+
148
+ const localIndex = filtered.findIndex((it) => it.index === activeIndex) ?? -1;
149
+
150
+ const delta = e.code === 'ArrowDown' ? 1 : e.code === 'ArrowUp' ? -1 : 0;
151
+
152
+ const newIndex = Math.abs(localIndex + delta + length) % length;
153
+
154
+ data.activeIndex = items.value[newIndex].index ?? -1;
155
+ };
156
+
157
+ const onFocusOut = (event: FocusEvent) => {
158
+ const relatedTarget = event.relatedTarget as Node | null;
159
+
160
+ if (!root.value?.contains(relatedTarget) && !list.value?.contains(relatedTarget)) {
161
+ data.open = false;
162
+ }
163
+ };
164
+ </script>
165
+ <template>
166
+ <div
167
+ ref="root"
168
+ :class="{ disabled: disabled || isLoadingOptions, loading: isLoadingOptions }"
169
+ class="pl-btn-split d-flex"
170
+ @focusout="onFocusOut"
171
+ @keydown="handleKeydown"
172
+ >
173
+ <div
174
+ ref="buttonAction"
175
+ class="pl-btn-split__title flex-grow-1 d-flex align-center text-s-btn"
176
+ tabindex="0"
177
+ @click="emitEnter"
178
+ @keyup.stop.enter="emitEnter"
179
+ >
180
+ {{ actionName }}
181
+ </div>
182
+ <div ref="menuActivator" class="pl-btn-split__icon-container d-flex align-center justify-center" tabindex="0" @click="data.open = !data.open">
183
+ <PlMaskIcon16 v-if="isLoadingOptions" name="loading" />
184
+ <div v-else :class="iconState" class="pl-btn-split__icon" />
185
+ </div>
186
+
187
+ <Teleport v-if="data.open" to="body">
188
+ <div ref="list" class="pl-dropdown__options" :style="optionsStyle" tabindex="-1">
189
+ <DropdownListItem
190
+ v-for="(item, index) in items"
191
+ :key="index"
192
+ :option="item"
193
+ :is-selected="item.isSelected"
194
+ :is-hovered="item.isActive"
195
+ :size="'medium'"
196
+ @click.stop="selectOption(item.value)"
197
+ />
198
+ </div>
199
+ </Teleport>
200
+ </div>
201
+ </template>
@@ -0,0 +1,179 @@
1
+ import { mount } from '@vue/test-utils';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import PlBtnSplit from '../PlBtnSplit.vue';
4
+
5
+ describe('PlBtnSplit.vue', () => {
6
+ it('Renders correctly with options', () => {
7
+ const wrapper = mount(PlBtnSplit, {
8
+ props: {
9
+ options: [
10
+ { label: 'Option 1', value: 'opt1' },
11
+ { label: 'Option 2', value: 'opt2' },
12
+ ],
13
+ disabled: false,
14
+ modelValue: 'opt1',
15
+ },
16
+ });
17
+
18
+ expect(wrapper.text()).toContain('Option 1');
19
+ });
20
+
21
+ it('Toggles dropdown on menu activator click', async () => {
22
+ const wrapper = mount(PlBtnSplit, {
23
+ props: {
24
+ modelValue: 'opt1',
25
+ options: [
26
+ { label: 'Option 1', value: 'opt1' },
27
+ { label: 'Option 2', value: 'opt2' },
28
+ ],
29
+ },
30
+ });
31
+
32
+ const menuActivator = wrapper.find('.pl-btn-split__icon-container');
33
+
34
+ console.log(menuActivator);
35
+ await menuActivator.trigger('click');
36
+
37
+ const dropdown = document.body.querySelector('.pl-dropdown__options');
38
+ expect(dropdown).toBeTruthy();
39
+ expect(dropdown?.textContent).toContain('Option 1');
40
+ expect(dropdown?.textContent).not.toContain('Option 3');
41
+
42
+ //Hide dropdown on focusout
43
+ wrapper.trigger('focusout');
44
+ await wrapper.vm.$nextTick();
45
+
46
+ // Убедимся, что dropdown закрылся
47
+ expect(document.body.querySelector('.pl-dropdown__options')).toBeFalsy();
48
+ });
49
+
50
+ it('Emits click event when button is clicked', async () => {
51
+ const clickHandler = vi.fn();
52
+
53
+ const wrapper = mount(PlBtnSplit, {
54
+ props: {
55
+ modelValue: undefined,
56
+ options: [],
57
+ },
58
+ attrs: {
59
+ onclick: clickHandler,
60
+ },
61
+ });
62
+
63
+ const button = wrapper.find('.pl-btn-split__title');
64
+ await button.trigger('click');
65
+ expect(wrapper.emitted('click')).toBeTruthy();
66
+ expect(clickHandler).toHaveBeenCalled();
67
+ });
68
+
69
+ it('Updates modelValue on option select', async () => {
70
+ const wrapper = mount(PlBtnSplit, {
71
+ props: {
72
+ modelValue: 'opt1',
73
+ options: [
74
+ { label: 'Option 1', value: 'opt1' },
75
+ { label: 'Option 2', value: 'opt2' },
76
+ ],
77
+ },
78
+ });
79
+
80
+ await wrapper.setProps({ modelValue: 'opt2' });
81
+ expect(wrapper.text()).toContain('Option 2');
82
+
83
+ await wrapper.setProps({ modelValue: undefined });
84
+ expect(wrapper.text()).toContain('');
85
+ });
86
+
87
+ it('Handles keyboard navigation correctly', async () => {
88
+ const wrapper = mount(PlBtnSplit, {
89
+ props: {
90
+ modelValue: 'opt1',
91
+ options: [
92
+ { label: 'Option 1', value: 'opt1' },
93
+ { label: 'Option 2', value: 'opt2' },
94
+ ],
95
+ },
96
+ });
97
+
98
+ const root = wrapper.find('.pl-btn-split');
99
+ await root.trigger('keydown', { code: 'ArrowDown' });
100
+
101
+ expect(wrapper.vm.data.activeIndex).toBe(0);
102
+
103
+ await root.trigger('keydown', { code: 'ArrowDown' });
104
+
105
+ expect(wrapper.vm.data.activeIndex).toBe(1);
106
+
107
+ await root.trigger('keydown', { code: 'Enter' });
108
+ expect(wrapper.emitted('update:modelValue')![0]).toEqual(['opt2']);
109
+ });
110
+
111
+ it('Loading status by empty options and display dots "..."', async () => {
112
+ const wrapper = mount(PlBtnSplit, {
113
+ props: {
114
+ modelValue: 'opt1',
115
+ options: undefined,
116
+ },
117
+ });
118
+
119
+ expect(wrapper.classes().includes('loading')).toBe(true);
120
+ const button = wrapper.find('.pl-btn-split__title');
121
+ expect(button.text()).toBe('...');
122
+ });
123
+
124
+ it('Loading status by empty options and empty model display dots "..."', async () => {
125
+ const wrapper = mount(PlBtnSplit, {
126
+ props: {
127
+ modelValue: undefined,
128
+ options: undefined,
129
+ },
130
+ });
131
+
132
+ expect(wrapper.classes().includes('loading')).toBe(true);
133
+ expect(wrapper.classes().includes('disabled')).toBe(true);
134
+ const button = wrapper.find('.pl-btn-split__title');
135
+ expect(button.text()).toBe('...');
136
+ });
137
+
138
+ it('No dots with undefined model', async () => {
139
+ const wrapper = mount(PlBtnSplit, {
140
+ props: {
141
+ modelValue: undefined,
142
+ options: [
143
+ { label: 'Option 1', value: 'opt1' },
144
+ { label: 'Option 2', value: 'opt2' },
145
+ ],
146
+ },
147
+ });
148
+
149
+ expect(wrapper.classes().includes('loading')).toBe(false);
150
+ expect(wrapper.classes().includes('disabled')).toBe(false);
151
+ const button = wrapper.find('.pl-btn-split__title');
152
+ expect(button.text()).toBe('');
153
+ });
154
+
155
+ it('Loading props', async () => {
156
+ const wrapper = mount(PlBtnSplit, {
157
+ props: {
158
+ modelValue: 'opt1',
159
+ loading: true,
160
+ options: [
161
+ { label: 'Option 1', value: 'opt1' },
162
+ { label: 'Option 2', value: 'opt2' },
163
+ ],
164
+ },
165
+ });
166
+
167
+ expect(wrapper.classes().includes('loading')).toBe(true);
168
+ expect(wrapper.classes().includes('disabled')).toBe(true);
169
+ const button = wrapper.find('.pl-btn-split__title');
170
+ expect(button.text()).toBe('Option 1');
171
+
172
+ wrapper.setProps({ loading: false });
173
+ await wrapper.vm.$nextTick();
174
+
175
+ expect(wrapper.classes().includes('loading')).toBe(false);
176
+ expect(wrapper.classes().includes('disabled')).toBe(false);
177
+ expect(button.text()).toBe('Option 1');
178
+ });
179
+ });
@@ -0,0 +1,4 @@
1
+ import PlBtnSplit from './PlBtnSplit.vue';
2
+
3
+ export { PlBtnSplit };
4
+ export default PlBtnSplit;
@@ -0,0 +1,93 @@
1
+ @mixin btn-focus {
2
+ &:focus-visible {
3
+ position: relative;
4
+ outline: none;
5
+
6
+ &::after {
7
+ content: '';
8
+ position: absolute;
9
+ inset: -1px;
10
+ border: 2px solid var(--border-color-focus);
11
+ border-radius: 6px;
12
+ z-index: 2;
13
+ }
14
+ }
15
+ }
16
+
17
+ .pl-btn-split {
18
+ $root: &;
19
+
20
+ --border-color: var(--border-color-default);
21
+
22
+ height: 40px;
23
+ min-width: 160px;
24
+ border-radius: 6px;
25
+ border: 1px solid var(--border-color);
26
+ cursor: pointer;
27
+
28
+
29
+
30
+ &.disabled,
31
+ &.loading {
32
+ pointer-events: none;
33
+ --border-color: var(--border-color-div-grey);
34
+ }
35
+
36
+ &.loading {
37
+ .mask-loading {
38
+ animation: spin 2.5s linear infinite;
39
+ }
40
+ }
41
+
42
+ &__title {
43
+ padding: 8px 14px;
44
+ height: 100%;
45
+ @include btn-focus;
46
+ color: var(--border-color);
47
+ transition: all .1s ease-in-out;
48
+
49
+ &:hover {
50
+ border-radius: 6px;
51
+ background-color: var(--btn-sec-hover-grey);
52
+ }
53
+
54
+ &:active {
55
+ border-radius: 6px;
56
+ background-color: var(--btn-sec-press-grey);
57
+ }
58
+ }
59
+
60
+ &__icon,
61
+ .mask-loading {
62
+ background-color: var(--border-color);
63
+ }
64
+
65
+ &__icon-container {
66
+ width: 36px;
67
+ height: 100%;
68
+ position: relative;
69
+ @include btn-focus;
70
+ transition: all .1s ease-in-out;
71
+
72
+
73
+
74
+ &:hover {
75
+ border-radius: 6px;
76
+ background-color: var(--btn-sec-hover-grey);
77
+ }
78
+
79
+ &:active {
80
+ border-radius: 6px;
81
+ background-color: var(--btn-sec-press-grey);
82
+ }
83
+
84
+ &::before {
85
+ content: '';
86
+ background: var(--border-color);
87
+ height: 26px;
88
+ position: absolute;
89
+ left: 0;
90
+ width: 1px;
91
+ }
92
+ }
93
+ }
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ export * from './layout/PlGrid';
18
18
  * Components
19
19
  */
20
20
  export * from './components/PlAlert';
21
+ export * from './components/PlBtnSplit';
21
22
  export * from './components/PlBtnPrimary';
22
23
  export * from './components/PlBtnAccent';
23
24
  export * from './components/PlBtnDanger';