@webitel/ui-sdk 26.8.125 → 26.8.127
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/dist/{clients-C8bQsU8l.js → clients-BIYo95Lm.js} +1 -1
- package/dist/{components-BBqsnlPE.js → components-ClBeAarp.js} +441 -351
- package/dist/ui-sdk.css +1 -1
- package/dist/ui-sdk.js +1 -1
- package/dist/ui-sdk.umd.cjs +649 -649
- package/dist/{wt-chat-emoji-BBo9lHMv.js → wt-chat-emoji-DScnZq3L.js} +1 -1
- package/dist/{wt-display-chip-items-D_pECxhW.js → wt-display-chip-items-T7QK4gFh.js} +1 -1
- package/dist/{wt-send-message-popup-f1bV8oUt.js → wt-send-message-popup-DXWOn8eg.js} +2 -2
- package/dist/{wt-type-extension-value-input-DpLKqfaa.js → wt-type-extension-value-input-BsIDJ-6K.js} +2 -2
- package/dist/{wt-vidstack-player-CGkzRJBI.js → wt-vidstack-player-C_miJ8JF.js} +1 -1
- package/package.json +2 -1
- package/src/components/index.js +9 -0
- package/src/components/wt-content-wrapper/__tests__/WtContentWrapper.spec.js +34 -0
- package/src/components/wt-content-wrapper/_variables.css +10 -0
- package/src/components/wt-content-wrapper/wt-content-wrapper.vue +46 -0
- package/src/components/wt-layout/__tests__/WtLayout.spec.js +192 -0
- package/src/components/wt-layout/_variables.css +15 -0
- package/src/components/wt-layout/wt-layout.vue +228 -0
- package/src/components/wt-page/__tests__/WtPage.spec.js +35 -0
- package/src/components/wt-page/_variables.css +15 -0
- package/src/components/wt-page/wt-page.vue +52 -0
- package/src/css/components-variables.css +3 -0
- package/types/.tsbuildinfo +1 -1
- package/types/components/index.d.ts +7 -1
- package/types/components/wt-content-wrapper/__tests__/WtContentWrapper.spec.d.ts +1 -0
- package/types/components/wt-content-wrapper/wt-content-wrapper.vue.d.ts +18 -0
- package/types/components/wt-layout/__tests__/WtLayout.spec.d.ts +1 -0
- package/types/components/wt-layout/wt-layout.vue.d.ts +36 -0
- package/types/components/wt-page/__tests__/WtPage.spec.d.ts +1 -0
- package/types/components/wt-page/wt-page.vue.d.ts +13 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as e, i as t } from "./components-
|
|
1
|
+
import { C as e, i as t } from "./components-ClBeAarp.js";
|
|
2
2
|
import { t as n } from "./_plugin-vue_export-helper-B3ysoDQm.js";
|
|
3
3
|
import { t as r } from "./utils-B4S9Yz-y.js";
|
|
4
4
|
import { Fragment as i, computed as a, createCommentVNode as o, createElementBlock as s, createTextVNode as c, createVNode as l, defineComponent as u, openBlock as d, renderList as f, renderSlot as p, toDisplayString as m, unref as h, withCtx as g } from "vue";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { n as e } from "./components-
|
|
1
|
+
import { n as e } from "./components-ClBeAarp.js";
|
|
2
2
|
import "./convertDuration-B5bq1uxb.js";
|
|
3
|
-
import { i as t, r as n } from "./clients-
|
|
3
|
+
import { i as t, r as n } from "./clients-BIYo95Lm.js";
|
|
4
4
|
import { n as r } from "./vue-i18n-VBiROMZ4.js";
|
|
5
5
|
import { n as i } from "./useEventBus-B-ZeklLu.js";
|
|
6
6
|
import { createBlock as a, createTextVNode as o, createVNode as s, defineComponent as c, mergeProps as l, onMounted as u, openBlock as d, ref as f, resolveComponent as p, toDisplayString as m, unref as h, withCtx as g } from "vue";
|
package/dist/{wt-type-extension-value-input-DpLKqfaa.js → wt-type-extension-value-input-BsIDJ-6K.js}
RENAMED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { a as e, c as t, i as n, n as r, r as i, s as a, t as o } from "./dist-EXdgfpvN.js";
|
|
2
2
|
import { n as s } from "./genClient-CvWlQ0TZ.js";
|
|
3
3
|
import { a as c } from "./caseConverters-DGri4uJo.js";
|
|
4
|
-
import { a as l, o as u, r as d, s as f } from "./components-
|
|
5
|
-
import { a as p, n as m, t as h } from "./clients-
|
|
4
|
+
import { a as l, o as u, r as d, s as f } from "./components-ClBeAarp.js";
|
|
5
|
+
import { a as p, n as m, t as h } from "./clients-BIYo95Lm.js";
|
|
6
6
|
import { a as g, b as _, d as v, f as y, g as b, i as x, n as S, o as C, r as w, s as T, t as E, x as D } from "./enums-Xag5PrU-.js";
|
|
7
7
|
import { n as ee, t as te } from "./vue-i18n-VBiROMZ4.js";
|
|
8
8
|
import "./wt-toast-BScwyGca.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webitel/ui-sdk",
|
|
3
|
-
"version": "26.8.
|
|
3
|
+
"version": "26.8.127",
|
|
4
4
|
"private": false,
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "npm run docs:dev",
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"@tinymce/tinymce-vue": "^6.3.0",
|
|
66
66
|
"@vuepic/vue-datepicker": "^4.5.1",
|
|
67
67
|
"@vueuse/components": "^14.0.0",
|
|
68
|
+
"@vueuse/core": "^14.0.0",
|
|
68
69
|
"autosize": "^6.0.1",
|
|
69
70
|
"csv-parse": "^7.0.2",
|
|
70
71
|
"csv-stringify": "^5.5.3",
|
package/src/components/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import WtCallMediaMetric from './wt-call-media-metric/wt-call-media-metric.vue';
|
|
|
14
14
|
import WtCard from './wt-card/wt-card.vue';
|
|
15
15
|
import WtCheckbox from './wt-checkbox/wt-checkbox.vue';
|
|
16
16
|
import WtChip from './wt-chip/wt-chip.vue';
|
|
17
|
+
import WtContentWrapper from './wt-content-wrapper/wt-content-wrapper.vue';
|
|
17
18
|
import WtDatetimeText from './wt-datetime-text/wt-datetime-text.vue';
|
|
18
19
|
import WtDivider from './wt-divider/wt-divider.vue';
|
|
19
20
|
import WtEmpty from './wt-empty/wt-empty.vue';
|
|
@@ -30,12 +31,14 @@ import WtInputText from './wt-input-text/wt-input-text.vue';
|
|
|
30
31
|
import WtIntersectionObserver from './wt-intersection-observer/wt-intersection-observer.vue';
|
|
31
32
|
import WtItemLink from './wt-item-link/wt-item-link.vue';
|
|
32
33
|
import WtLabel from './wt-label/wt-label.vue';
|
|
34
|
+
import WtLayout from './wt-layout/wt-layout.vue';
|
|
33
35
|
import WtLoadBar from './wt-load-bar/wt-load-bar.vue';
|
|
34
36
|
import WtLoader from './wt-loader/wt-loader.vue';
|
|
35
37
|
import WtLogo from './wt-logo/wt-logo.vue';
|
|
36
38
|
import WtMessage from './wt-message/wt-message.vue';
|
|
37
39
|
import WtMultiSelect from './wt-multi-select/wt-multi-select.vue';
|
|
38
40
|
import WtNavigationBar from './wt-navigation-bar/wt-navigation-bar.vue';
|
|
41
|
+
import WtPage from './wt-page/wt-page.vue';
|
|
39
42
|
import WtPageWrapper from './wt-page-wrapper/wt-page-wrapper.vue';
|
|
40
43
|
import WtPassword from './wt-password/wt-password.vue';
|
|
41
44
|
import WtPopup from './wt-popup/wt-popup.vue';
|
|
@@ -239,9 +242,11 @@ const Components = {
|
|
|
239
242
|
WtButton,
|
|
240
243
|
WtChip,
|
|
241
244
|
WtConfirmDialog,
|
|
245
|
+
WtContentWrapper,
|
|
242
246
|
WtDivider,
|
|
243
247
|
WtTooltip,
|
|
244
248
|
WtLabel,
|
|
249
|
+
WtLayout,
|
|
245
250
|
WtLoader,
|
|
246
251
|
WtRoundedAction,
|
|
247
252
|
WtReplaceTransition,
|
|
@@ -274,6 +279,7 @@ const Components = {
|
|
|
274
279
|
WtHeaderActions,
|
|
275
280
|
WtErrorPage,
|
|
276
281
|
WtNotificationsBar,
|
|
282
|
+
WtPage,
|
|
277
283
|
WtPageWrapper,
|
|
278
284
|
WtDualPanel,
|
|
279
285
|
WtPagination,
|
|
@@ -331,6 +337,7 @@ export {
|
|
|
331
337
|
WtCheckbox,
|
|
332
338
|
WtChip,
|
|
333
339
|
WtConfirmDialog,
|
|
340
|
+
WtContentWrapper,
|
|
334
341
|
WtContextMenu,
|
|
335
342
|
WtCopyAction,
|
|
336
343
|
WtDatepicker,
|
|
@@ -359,6 +366,7 @@ export {
|
|
|
359
366
|
WtIntersectionObserver,
|
|
360
367
|
WtItemLink,
|
|
361
368
|
WtLabel,
|
|
369
|
+
WtLayout,
|
|
362
370
|
WtLoadBar,
|
|
363
371
|
WtLoader,
|
|
364
372
|
WtLogo,
|
|
@@ -367,6 +375,7 @@ export {
|
|
|
367
375
|
WtNavigationBar,
|
|
368
376
|
WtNavigationMenu,
|
|
369
377
|
WtNotificationsBar,
|
|
378
|
+
WtPage,
|
|
370
379
|
WtPageHeader,
|
|
371
380
|
WtPageWrapper,
|
|
372
381
|
WtPagination,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { shallowMount } from '@vue/test-utils';
|
|
2
|
+
|
|
3
|
+
import WtContentWrapper from '../wt-content-wrapper.vue';
|
|
4
|
+
|
|
5
|
+
describe('WtContentWrapper', () => {
|
|
6
|
+
it('renders a component', () => {
|
|
7
|
+
const wrapper = shallowMount(WtContentWrapper);
|
|
8
|
+
expect(wrapper.classes('wt-content-wrapper')).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('renders content via default slot', () => {
|
|
12
|
+
const content = 'Content';
|
|
13
|
+
const wrapper = shallowMount(WtContentWrapper, {
|
|
14
|
+
slots: {
|
|
15
|
+
default: content,
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
expect(wrapper.text()).toBe(content);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('aligns content to the left by default', () => {
|
|
22
|
+
const wrapper = shallowMount(WtContentWrapper);
|
|
23
|
+
expect(wrapper.classes('wt-content-wrapper--align-left')).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('applies align prop', () => {
|
|
27
|
+
const wrapper = shallowMount(WtContentWrapper, {
|
|
28
|
+
props: {
|
|
29
|
+
align: 'stretch',
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
expect(wrapper.classes('wt-content-wrapper--align-stretch')).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--wt-content-wrapper-background-color: var(--p-surface-0);
|
|
3
|
+
--wt-content-wrapper-border-radius: var(--p-border-radius-lg);
|
|
4
|
+
--wt-content-wrapper-padding: var(--spacing-sm);
|
|
5
|
+
--wt-content-wrapper-gap: var(--spacing-sm);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
:root.theme--dark {
|
|
9
|
+
--wt-content-wrapper-background-color: var(--p-surface-800);
|
|
10
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div :class="`wt-content-wrapper--align-${align}`" class="wt-content-wrapper">
|
|
3
|
+
<slot />
|
|
4
|
+
</div>
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script setup lang="ts">
|
|
8
|
+
/**
|
|
9
|
+
* Structural container inside `wt-layout`: owns inner padding, gap and
|
|
10
|
+
* horizontal alignment of its content. No business logic, no own visuals
|
|
11
|
+
* beyond the surface.
|
|
12
|
+
*/
|
|
13
|
+
interface Props {
|
|
14
|
+
align?: 'left' | 'center' | 'stretch';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
withDefaults(defineProps<Props>(), {
|
|
18
|
+
align: 'left',
|
|
19
|
+
});
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<style scoped>
|
|
23
|
+
.wt-content-wrapper {
|
|
24
|
+
display: flex;
|
|
25
|
+
flex: 0 0 auto;
|
|
26
|
+
flex-direction: column;
|
|
27
|
+
gap: var(--wt-content-wrapper-gap);
|
|
28
|
+
box-sizing: border-box;
|
|
29
|
+
border-radius: var(--wt-content-wrapper-border-radius);
|
|
30
|
+
background: var(--wt-content-wrapper-background-color);
|
|
31
|
+
padding: var(--wt-content-wrapper-padding);
|
|
32
|
+
min-width: 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.wt-content-wrapper--align-left {
|
|
36
|
+
align-items: flex-start;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.wt-content-wrapper--align-center {
|
|
40
|
+
align-items: center;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.wt-content-wrapper--align-stretch {
|
|
44
|
+
align-items: stretch;
|
|
45
|
+
}
|
|
46
|
+
</style>
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { flushPromises, mount, shallowMount } from '@vue/test-utils';
|
|
2
|
+
|
|
3
|
+
import WtLayout from '../wt-layout.vue';
|
|
4
|
+
|
|
5
|
+
const mockRect = (el, width) => {
|
|
6
|
+
el.getBoundingClientRect = () => ({
|
|
7
|
+
width,
|
|
8
|
+
});
|
|
9
|
+
el.style.minWidth = '320px';
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const mountPair = (props) => {
|
|
13
|
+
const Host = {
|
|
14
|
+
components: {
|
|
15
|
+
WtLayout,
|
|
16
|
+
},
|
|
17
|
+
template: `
|
|
18
|
+
<div>
|
|
19
|
+
<wt-layout v-bind="layoutProps" @resize="onResize" />
|
|
20
|
+
<wt-layout class="neighbor" />
|
|
21
|
+
</div>
|
|
22
|
+
`,
|
|
23
|
+
data: () => ({
|
|
24
|
+
layoutProps: props,
|
|
25
|
+
resized: [],
|
|
26
|
+
}),
|
|
27
|
+
methods: {
|
|
28
|
+
onResize(width) {
|
|
29
|
+
this.resized.push(width);
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
const wrapper = mount(Host, {
|
|
34
|
+
attachTo: document.body,
|
|
35
|
+
});
|
|
36
|
+
const [layout, neighbor] = wrapper.findAll('.wt-layout');
|
|
37
|
+
mockRect(layout.element, 400);
|
|
38
|
+
mockRect(neighbor.element, 600);
|
|
39
|
+
return {
|
|
40
|
+
wrapper,
|
|
41
|
+
layout,
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
describe('WtLayout', () => {
|
|
46
|
+
it('renders a component', () => {
|
|
47
|
+
const wrapper = shallowMount(WtLayout);
|
|
48
|
+
expect(wrapper.classes('wt-layout')).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('renders content via default slot', () => {
|
|
52
|
+
const content = 'Layout content';
|
|
53
|
+
const wrapper = shallowMount(WtLayout, {
|
|
54
|
+
slots: {
|
|
55
|
+
default: content,
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
expect(wrapper.text()).toBe(content);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('is fluid without defaultWidth', () => {
|
|
62
|
+
const wrapper = shallowMount(WtLayout);
|
|
63
|
+
expect(wrapper.classes('wt-layout--fixed')).toBe(false);
|
|
64
|
+
expect(wrapper.attributes('style')).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('starts at defaultWidth', () => {
|
|
68
|
+
const wrapper = shallowMount(WtLayout, {
|
|
69
|
+
props: {
|
|
70
|
+
defaultWidth: 320,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
expect(wrapper.classes('wt-layout--fixed')).toBe(true);
|
|
74
|
+
expect(wrapper.element.style.flexBasis).toBe('320px');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('renders resizer only when resizable', async () => {
|
|
78
|
+
const wrapper = shallowMount(WtLayout);
|
|
79
|
+
expect(wrapper.find('.wt-layout__resizer').exists()).toBe(false);
|
|
80
|
+
await wrapper.setProps({
|
|
81
|
+
resizable: true,
|
|
82
|
+
});
|
|
83
|
+
expect(wrapper.find('.wt-layout__resizer').exists()).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('resizes by dragging, bounded by the neighbor min width', async () => {
|
|
87
|
+
const { wrapper, layout } = mountPair({
|
|
88
|
+
resizable: true,
|
|
89
|
+
});
|
|
90
|
+
const resizer = layout.find('.wt-layout__resizer');
|
|
91
|
+
|
|
92
|
+
await resizer.trigger('pointerdown', {
|
|
93
|
+
button: 0,
|
|
94
|
+
clientX: 400,
|
|
95
|
+
});
|
|
96
|
+
window.dispatchEvent(
|
|
97
|
+
new PointerEvent('pointermove', {
|
|
98
|
+
clientX: 500,
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
await wrapper.vm.$nextTick();
|
|
102
|
+
expect(layout.element.style.flexBasis).toBe('500px');
|
|
103
|
+
|
|
104
|
+
// neighbor is 600px wide with 320px min => max 400 + 280 = 680
|
|
105
|
+
window.dispatchEvent(
|
|
106
|
+
new PointerEvent('pointermove', {
|
|
107
|
+
clientX: 2000,
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
window.dispatchEvent(new PointerEvent('pointerup'));
|
|
111
|
+
await wrapper.vm.$nextTick();
|
|
112
|
+
expect(layout.element.style.flexBasis).toBe('680px');
|
|
113
|
+
expect(wrapper.vm.resized).toEqual([
|
|
114
|
+
680,
|
|
115
|
+
]);
|
|
116
|
+
wrapper.unmount();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('does not shrink below its min width', async () => {
|
|
120
|
+
const { wrapper, layout } = mountPair({
|
|
121
|
+
resizable: true,
|
|
122
|
+
});
|
|
123
|
+
const resizer = layout.find('.wt-layout__resizer');
|
|
124
|
+
|
|
125
|
+
await resizer.trigger('pointerdown', {
|
|
126
|
+
button: 0,
|
|
127
|
+
clientX: 400,
|
|
128
|
+
});
|
|
129
|
+
window.dispatchEvent(
|
|
130
|
+
new PointerEvent('pointermove', {
|
|
131
|
+
clientX: 0,
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
window.dispatchEvent(new PointerEvent('pointerup'));
|
|
135
|
+
await wrapper.vm.$nextTick();
|
|
136
|
+
expect(layout.element.style.flexBasis).toBe('320px');
|
|
137
|
+
wrapper.unmount();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('reports the rendered width, keeping the preferred one when shrunk', async () => {
|
|
141
|
+
let notify;
|
|
142
|
+
vi.stubGlobal(
|
|
143
|
+
'ResizeObserver',
|
|
144
|
+
class {
|
|
145
|
+
constructor(callback) {
|
|
146
|
+
notify = callback;
|
|
147
|
+
}
|
|
148
|
+
observe() {}
|
|
149
|
+
disconnect() {}
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
const wrapper = shallowMount(WtLayout, {
|
|
153
|
+
props: {
|
|
154
|
+
defaultWidth: 480,
|
|
155
|
+
resizable: true,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
await flushPromises();
|
|
159
|
+
// container got narrower: flex shrinks the layout below its preferred width
|
|
160
|
+
notify([
|
|
161
|
+
{
|
|
162
|
+
borderBoxSize: [
|
|
163
|
+
{
|
|
164
|
+
inlineSize: 400.4,
|
|
165
|
+
blockSize: 600,
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
]);
|
|
170
|
+
await wrapper.vm.$nextTick();
|
|
171
|
+
|
|
172
|
+
expect(
|
|
173
|
+
wrapper.find('.wt-layout__resizer').attributes('aria-valuenow'),
|
|
174
|
+
).toBe('400');
|
|
175
|
+
expect(wrapper.element.style.flexBasis).toBe('480px');
|
|
176
|
+
vi.unstubAllGlobals();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('resizes with arrow keys', async () => {
|
|
180
|
+
const { wrapper, layout } = mountPair({
|
|
181
|
+
resizable: true,
|
|
182
|
+
});
|
|
183
|
+
await layout.find('.wt-layout__resizer').trigger('keydown', {
|
|
184
|
+
key: 'ArrowRight',
|
|
185
|
+
});
|
|
186
|
+
expect(layout.element.style.flexBasis).toBe('416px');
|
|
187
|
+
expect(wrapper.vm.resized).toEqual([
|
|
188
|
+
416,
|
|
189
|
+
]);
|
|
190
|
+
wrapper.unmount();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--wt-layout-background-color: var(--p-surface-0);
|
|
3
|
+
--wt-layout-border-radius: var(--p-border-radius-lg);
|
|
4
|
+
--wt-layout-padding: 0;
|
|
5
|
+
--wt-layout-gap: 0;
|
|
6
|
+
--wt-layout-min-width: 320px;
|
|
7
|
+
|
|
8
|
+
--wt-layout-resizer-width: var(--spacing-xs);
|
|
9
|
+
--wt-layout-resizer-line-width: 2px;
|
|
10
|
+
--wt-layout-resizer-active-color: var(--p-primary-color);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
:root.theme--dark {
|
|
14
|
+
--wt-layout-background-color: var(--p-surface-800);
|
|
15
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section
|
|
3
|
+
ref="root"
|
|
4
|
+
:class="{
|
|
5
|
+
'wt-layout--fixed': width !== undefined,
|
|
6
|
+
'wt-layout--resizing': resizing,
|
|
7
|
+
}"
|
|
8
|
+
:style="width !== undefined ? { flexBasis: `${width}px` } : undefined"
|
|
9
|
+
class="wt-layout"
|
|
10
|
+
>
|
|
11
|
+
<slot />
|
|
12
|
+
<div
|
|
13
|
+
v-if="resizable"
|
|
14
|
+
:aria-valuenow="Math.round(renderedWidth)"
|
|
15
|
+
:class="`wt-layout__resizer--${resizeEdge}`"
|
|
16
|
+
aria-orientation="vertical"
|
|
17
|
+
class="wt-layout__resizer"
|
|
18
|
+
role="separator"
|
|
19
|
+
tabindex="0"
|
|
20
|
+
@keydown="handleResizerKeydown"
|
|
21
|
+
@pointerdown="startResize"
|
|
22
|
+
/>
|
|
23
|
+
</section>
|
|
24
|
+
</template>
|
|
25
|
+
|
|
26
|
+
<script setup lang="ts">
|
|
27
|
+
import { useElementSize } from '@vueuse/core';
|
|
28
|
+
import { onBeforeUnmount, ref, useTemplateRef, watch } from 'vue';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Structural container inside `wt-page` body. Fills available width
|
|
32
|
+
* (min 320px) unless `defaultWidth` is set; can be resized by dragging
|
|
33
|
+
* its edge, the adjacent layout compensates. To hide a layout, the parent
|
|
34
|
+
* (i.e. its sibling's owner) removes it with `v-if` / `v-show` — the rest
|
|
35
|
+
* redistribute the space automatically.
|
|
36
|
+
*/
|
|
37
|
+
interface Props {
|
|
38
|
+
/** Initial width in px. Without it, the layout is fluid. */
|
|
39
|
+
defaultWidth?: number;
|
|
40
|
+
/** Allows the user to change the width by dragging the edge. */
|
|
41
|
+
resizable?: boolean;
|
|
42
|
+
/** Which edge carries the resize handle. The neighbor on that side compensates. */
|
|
43
|
+
resizeEdge?: 'start' | 'end';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
47
|
+
defaultWidth: undefined,
|
|
48
|
+
resizable: false,
|
|
49
|
+
resizeEdge: 'end',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const emit = defineEmits<{
|
|
53
|
+
/** Emitted once a resize is finished, with the new width in px. */
|
|
54
|
+
resize: [
|
|
55
|
+
width: number,
|
|
56
|
+
];
|
|
57
|
+
}>();
|
|
58
|
+
|
|
59
|
+
const KEYBOARD_STEP = 16;
|
|
60
|
+
const KEY_DIRECTION: Partial<Record<string, 1 | -1>> = {
|
|
61
|
+
ArrowLeft: -1,
|
|
62
|
+
ArrowRight: 1,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const root = useTemplateRef<HTMLElement>('root');
|
|
66
|
+
/**
|
|
67
|
+
* Preferred width: what the user dragged to. A fixed layout may still render
|
|
68
|
+
* narrower (it shrinks with its container) and returns to this width once
|
|
69
|
+
* there is room again.
|
|
70
|
+
*/
|
|
71
|
+
const width = ref<number | undefined>(props.defaultWidth);
|
|
72
|
+
/** Width actually on screen, kept in sync with container resizes. */
|
|
73
|
+
const { width: renderedWidth } = useElementSize(root, undefined, {
|
|
74
|
+
box: 'border-box',
|
|
75
|
+
});
|
|
76
|
+
const resizing = ref(false);
|
|
77
|
+
|
|
78
|
+
watch(
|
|
79
|
+
() => props.defaultWidth,
|
|
80
|
+
(value) => {
|
|
81
|
+
width.value = value;
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const getWidthBounds = () => {
|
|
86
|
+
const el = root.value as HTMLElement;
|
|
87
|
+
const current = el.getBoundingClientRect().width;
|
|
88
|
+
const min = Number.parseFloat(getComputedStyle(el).minWidth) || 0;
|
|
89
|
+
const neighbor =
|
|
90
|
+
props.resizeEdge === 'end'
|
|
91
|
+
? el.nextElementSibling
|
|
92
|
+
: el.previousElementSibling;
|
|
93
|
+
if (!(neighbor instanceof HTMLElement)) {
|
|
94
|
+
return {
|
|
95
|
+
current,
|
|
96
|
+
min,
|
|
97
|
+
max: Number.POSITIVE_INFINITY,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const neighborMin =
|
|
101
|
+
Number.parseFloat(getComputedStyle(neighbor).minWidth) || 0;
|
|
102
|
+
const max = current + neighbor.getBoundingClientRect().width - neighborMin;
|
|
103
|
+
return {
|
|
104
|
+
current,
|
|
105
|
+
min,
|
|
106
|
+
max: Math.max(min, max),
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const clamp = (value: number, min: number, max: number) =>
|
|
111
|
+
Math.min(Math.max(value, min), max);
|
|
112
|
+
|
|
113
|
+
let stopResize: (() => void) | null = null;
|
|
114
|
+
|
|
115
|
+
const startResize = (event: PointerEvent) => {
|
|
116
|
+
if (!root.value || event.button !== 0) return;
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
|
|
119
|
+
const { current, min, max } = getWidthBounds();
|
|
120
|
+
const startX = event.clientX;
|
|
121
|
+
const direction = props.resizeEdge === 'end' ? 1 : -1;
|
|
122
|
+
width.value = current;
|
|
123
|
+
resizing.value = true;
|
|
124
|
+
|
|
125
|
+
const onMove = (moveEvent: PointerEvent) => {
|
|
126
|
+
const delta = (moveEvent.clientX - startX) * direction;
|
|
127
|
+
width.value = clamp(current + delta, min, max);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const onUp = () => {
|
|
131
|
+
stopResize?.();
|
|
132
|
+
emit('resize', width.value as number);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
stopResize = () => {
|
|
136
|
+
window.removeEventListener('pointermove', onMove);
|
|
137
|
+
window.removeEventListener('pointerup', onUp);
|
|
138
|
+
window.removeEventListener('pointercancel', onUp);
|
|
139
|
+
resizing.value = false;
|
|
140
|
+
stopResize = null;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
window.addEventListener('pointermove', onMove);
|
|
144
|
+
window.addEventListener('pointerup', onUp);
|
|
145
|
+
window.addEventListener('pointercancel', onUp);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const handleResizerKeydown = (event: KeyboardEvent) => {
|
|
149
|
+
if (!root.value) return;
|
|
150
|
+
const keyDirection = KEY_DIRECTION[event.key];
|
|
151
|
+
if (!keyDirection) return;
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
|
|
154
|
+
const { current, min, max } = getWidthBounds();
|
|
155
|
+
const direction = props.resizeEdge === 'end' ? 1 : -1;
|
|
156
|
+
width.value = clamp(
|
|
157
|
+
current + keyDirection * direction * KEYBOARD_STEP,
|
|
158
|
+
min,
|
|
159
|
+
max,
|
|
160
|
+
);
|
|
161
|
+
emit('resize', width.value);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
onBeforeUnmount(() => stopResize?.());
|
|
165
|
+
</script>
|
|
166
|
+
|
|
167
|
+
<style scoped>
|
|
168
|
+
.wt-layout {
|
|
169
|
+
display: flex;
|
|
170
|
+
position: relative;
|
|
171
|
+
flex: 1 1 0;
|
|
172
|
+
flex-direction: column;
|
|
173
|
+
gap: var(--wt-layout-gap);
|
|
174
|
+
box-sizing: border-box;
|
|
175
|
+
border-radius: var(--wt-layout-border-radius);
|
|
176
|
+
background: var(--wt-layout-background-color);
|
|
177
|
+
padding: var(--wt-layout-padding);
|
|
178
|
+
min-width: var(--wt-layout-min-width);
|
|
179
|
+
min-height: 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.wt-layout--fixed {
|
|
183
|
+
flex-grow: 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.wt-layout--resizing {
|
|
187
|
+
user-select: none;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.wt-layout__resizer {
|
|
191
|
+
position: absolute;
|
|
192
|
+
top: 0;
|
|
193
|
+
bottom: 0;
|
|
194
|
+
z-index: 1;
|
|
195
|
+
cursor: col-resize;
|
|
196
|
+
width: var(--wt-layout-resizer-width);
|
|
197
|
+
touch-action: none;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.wt-layout__resizer--end {
|
|
201
|
+
right: calc(var(--wt-layout-resizer-width) * -1);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.wt-layout__resizer--start {
|
|
205
|
+
left: calc(var(--wt-layout-resizer-width) * -1);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.wt-layout__resizer::after {
|
|
209
|
+
position: absolute;
|
|
210
|
+
top: 0;
|
|
211
|
+
bottom: 0;
|
|
212
|
+
left: calc(50% - var(--wt-layout-resizer-line-width) / 2);
|
|
213
|
+
transition: var(--transition);
|
|
214
|
+
background: transparent;
|
|
215
|
+
width: var(--wt-layout-resizer-line-width);
|
|
216
|
+
content: '';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.wt-layout__resizer:hover::after,
|
|
220
|
+
.wt-layout__resizer:focus-visible::after,
|
|
221
|
+
.wt-layout--resizing .wt-layout__resizer::after {
|
|
222
|
+
background: var(--wt-layout-resizer-active-color);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.wt-layout__resizer:focus-visible {
|
|
226
|
+
outline: none;
|
|
227
|
+
}
|
|
228
|
+
</style>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { shallowMount } from '@vue/test-utils';
|
|
2
|
+
|
|
3
|
+
import WtPage from '../wt-page.vue';
|
|
4
|
+
|
|
5
|
+
describe('WtPage', () => {
|
|
6
|
+
it('renders a component', () => {
|
|
7
|
+
const wrapper = shallowMount(WtPage);
|
|
8
|
+
expect(wrapper.classes('wt-page')).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('renders layouts into the body via default slot', () => {
|
|
12
|
+
const content = 'Page layouts';
|
|
13
|
+
const wrapper = shallowMount(WtPage, {
|
|
14
|
+
slots: {
|
|
15
|
+
default: content,
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
expect(wrapper.find('.wt-page__body').text()).toBe(content);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('renders header via header slot', () => {
|
|
22
|
+
const content = 'Page header';
|
|
23
|
+
const wrapper = shallowMount(WtPage, {
|
|
24
|
+
slots: {
|
|
25
|
+
header: content,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
expect(wrapper.find('.wt-page__header').text()).toBe(content);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('omits header section without header slot', () => {
|
|
32
|
+
const wrapper = shallowMount(WtPage);
|
|
33
|
+
expect(wrapper.find('.wt-page__header').exists()).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|