@mozaic-ds/vue 2.19.2 → 2.20.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/dist/mozaic-vue.css +1 -1
- package/dist/mozaic-vue.d.ts +512 -296
- package/dist/mozaic-vue.js +2005 -1758
- package/dist/mozaic-vue.js.map +1 -1
- package/dist/mozaic-vue.umd.cjs +6 -6
- package/dist/mozaic-vue.umd.cjs.map +1 -1
- package/package.json +5 -2
- package/src/components/Catalog.scss +84 -0
- package/src/components/Catalog.stories.ts +146 -0
- package/src/components/GettingStarted.mdx +258 -2
- package/src/components/Migration.mdx +4135 -532
- package/src/components/Welcome.mdx +1 -1
- package/src/components/carousel/MCarousel.spec.ts +174 -55
- package/src/components/carousel/MCarousel.vue +106 -33
- package/src/components/checkboxgroup/MCheckboxGroup.vue +2 -9
- package/src/components/combobox/MCombobox.spec.ts +144 -0
- package/src/components/combobox/MCombobox.stories.ts +2 -1
- package/src/components/combobox/MCombobox.vue +68 -38
- package/src/components/combobox/README.md +6 -0
- package/src/components/datatable/DataTable.stories.ts +2 -2
- package/src/components/datatable/DataTableCells.stories.ts +1 -0
- package/src/components/datatable/DataTableEmpty.stories.ts +1 -0
- package/src/components/datatable/DataTableExpandable.stories.ts +1 -0
- package/src/components/datatable/DataTableNested.stories.ts +1 -0
- package/src/components/datatable/DataTablePageable.stories.ts +194 -0
- package/src/components/datatable/DataTableSelectable.stories.ts +1 -0
- package/src/components/datatable/DataTableSortable.stories.ts +1 -0
- package/src/components/datepicker/MDatepicker.stories.ts +2 -0
- package/src/components/datepicker/MDatepicker.vue +17 -11
- package/src/components/divider/README.md +2 -0
- package/src/components/drawer/MDrawer.vue +7 -5
- package/src/components/link/MLink.stories.ts +18 -0
- package/src/components/link/MLink.vue +20 -4
- package/src/components/link/README.md +2 -1
- package/src/components/optionListbox/MOptionListbox.spec.ts +78 -1
- package/src/components/optionListbox/MOptionListbox.types.ts +7 -0
- package/src/components/optionListbox/MOptionListbox.vue +31 -217
- package/src/components/optionListbox/README.md +2 -0
- package/src/components/optionListbox/use-listbox-navigation.composable.ts +147 -0
- package/src/components/optionListbox/use-listbox-selection.composable.ts +149 -0
- package/src/components/pageheader/MPageHeader.spec.ts +17 -3
- package/src/components/pageheader/MPageHeader.stories.ts +37 -41
- package/src/components/pageheader/MPageHeader.vue +26 -16
- package/src/components/pageheader/README.md +2 -1
- package/src/components/pageheadercontext/MPageHeaderContext.spec.ts +155 -0
- package/src/components/pageheadercontext/MPageHeaderContext.vue +126 -0
- package/src/components/pageheadercontext/README.md +33 -0
- package/src/components/pageheaderfeature/MPageHeaderFeature.spec.ts +39 -0
- package/src/components/pageheaderfeature/MPageHeaderFeature.vue +40 -0
- package/src/components/pageheaderfeature/README.md +17 -0
- package/src/components/radiogroup/MRadioGroup.stories.ts +6 -0
- package/src/components/radiogroup/MRadioGroup.vue +2 -9
- package/src/components/sidebar/MSidebar.const.ts +2 -0
- package/src/components/sidebar/MSidebar.stories.ts +33 -0
- package/src/components/sidebar/MSidebar.vue +10 -1
- package/src/components/sidebar/README.md +1 -0
- package/src/components/sidebar/stories/DefaultCase.stories.vue +12 -11
- package/src/components/sidebar/use-floating-item.composable.ts +1 -1
- package/src/components/sidebarexpandableitem/MSidebarExpandableItem.vue +13 -7
- package/src/components/sidebarexpandableitem/README.md +2 -1
- package/src/components/sidebarfooter/MSidebarFooter.vue +16 -9
- package/src/components/sidebarfooter/_MSidebarFooterMenu.vue +10 -2
- package/src/components/sidebarnavitem/MSidebarNavItem.vue +36 -5
- package/src/components/sidebarnavitem/README.md +1 -0
- package/src/components/sidebarshortcutitem/MSidebarShortcutItem.vue +24 -3
- package/src/components/sidebarshortcutitem/README.md +3 -1
- package/src/components/sidebarshortcuts/MSidebarShortcuts.vue +6 -1
- package/src/components/sidebarshortcuts/README.md +12 -0
- package/src/components/statusbadge/MStatusBadge.stories.ts +5 -1
- package/src/components/statusbadge/MStatusBadge.vue +7 -1
- package/src/components/statusbadge/MstatusBadge.spec.ts +31 -0
- package/src/components/statusbadge/README.md +1 -0
- package/src/components/statusmessage/MStatusMessage.spec.ts +4 -4
- package/src/components/statusmessage/MStatusMessage.vue +5 -10
- package/src/components/statusnotification/MStatusNotification.spec.ts +9 -9
- package/src/components/statusnotification/MStatusNotification.vue +9 -8
- package/src/components/tabs/MTabs.stories.ts +30 -1
- package/src/components/tabs/MTabs.vue +12 -3
- package/src/components/tabs/Mtabs.spec.ts +19 -0
- package/src/components/tabs/README.md +1 -0
- package/src/components/tileselectable/MTileSelectable.spec.ts +69 -0
- package/src/components/tileselectable/MTileSelectable.stories.ts +8 -2
- package/src/components/tileselectable/MTileSelectable.vue +18 -1
- package/src/components/tileselectable/README.md +1 -0
- package/src/main.ts +3 -0
- package/src/utils/debounce.ts +15 -0
- package/src/components/BrandPresets.mdx +0 -146
- package/src/components/DarkMode.mdx +0 -115
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { computed } from 'vue';
|
|
2
|
+
import type { ListboxOption } from './MOptionListbox.types';
|
|
3
|
+
|
|
4
|
+
type SelectionValue = string | number | null | (string | number)[];
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
modelValue: SelectionValue;
|
|
8
|
+
options: ListboxOption[];
|
|
9
|
+
multiple?: boolean;
|
|
10
|
+
readonly?: boolean;
|
|
11
|
+
checkableSections?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function useListboxSelection(
|
|
15
|
+
props: Props,
|
|
16
|
+
emit: (on: 'update:modelValue', value: SelectionValue) => void,
|
|
17
|
+
) {
|
|
18
|
+
const selection = computed<SelectionValue>({
|
|
19
|
+
get() {
|
|
20
|
+
return props.modelValue;
|
|
21
|
+
},
|
|
22
|
+
set(value) {
|
|
23
|
+
emit('update:modelValue', value);
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const sectionMap = computed(() => {
|
|
28
|
+
const map = new Map<string, ListboxOption[]>();
|
|
29
|
+
let currentSection: ListboxOption | null = null;
|
|
30
|
+
|
|
31
|
+
props.options.forEach((option) => {
|
|
32
|
+
if (option.type === 'section') {
|
|
33
|
+
currentSection = option;
|
|
34
|
+
map.set(currentSection?.value?.toString() || currentSection.label, []);
|
|
35
|
+
} else if (currentSection) {
|
|
36
|
+
map
|
|
37
|
+
.get(currentSection?.value?.toString() || currentSection.label)!
|
|
38
|
+
.push(option);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return map;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function isSelectable(item: ListboxOption): boolean {
|
|
46
|
+
return !!(
|
|
47
|
+
(item.type !== 'section' && !item.disabled) ||
|
|
48
|
+
(item.type === 'section' && props.checkableSections && props.multiple)
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isOptionSelected(item: ListboxOption) {
|
|
53
|
+
if (item.value === undefined || item.value === null) return false;
|
|
54
|
+
|
|
55
|
+
if (Array.isArray(selection.value)) {
|
|
56
|
+
return (selection.value as (string | number)[]).includes(item.value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return item.value === selection.value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isSectionSelected(item: ListboxOption) {
|
|
63
|
+
if (!props.checkableSections || !props.multiple) return false;
|
|
64
|
+
|
|
65
|
+
const sectionItems =
|
|
66
|
+
sectionMap.value.get(item.value?.toString() || item.label) || [];
|
|
67
|
+
const selectedItems = Array.isArray(selection.value)
|
|
68
|
+
? (selection.value as (string | number)[])
|
|
69
|
+
: [];
|
|
70
|
+
|
|
71
|
+
return sectionItems.every((opt) => selectedItems.includes(opt.value!));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isIndeterminate(item: ListboxOption) {
|
|
75
|
+
const section = sectionMap.value.get(item.value?.toString() || item.label);
|
|
76
|
+
return (
|
|
77
|
+
section?.some((option) => isOptionSelected(option)) &&
|
|
78
|
+
!section?.every((option) => isOptionSelected(option))
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function toggleValue(item?: ListboxOption) {
|
|
83
|
+
if (!item || !isSelectable(item)) return;
|
|
84
|
+
|
|
85
|
+
if (Array.isArray(selection.value)) {
|
|
86
|
+
if (isOptionSelected(item)) {
|
|
87
|
+
selection.value = (selection.value as (string | number)[]).filter(
|
|
88
|
+
(el) => el !== item.value,
|
|
89
|
+
);
|
|
90
|
+
} else {
|
|
91
|
+
selection.value = [...selection.value, item.value!];
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
selection.value = isOptionSelected(item) ? null : item.value!;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function toggleSection(item: ListboxOption) {
|
|
99
|
+
if (!props.checkableSections || !props.multiple) return;
|
|
100
|
+
|
|
101
|
+
const sectionItems =
|
|
102
|
+
sectionMap.value.get(item.value?.toString() || item.label) || [];
|
|
103
|
+
const selectedItems = Array.isArray(selection.value)
|
|
104
|
+
? (selection.value as (string | number)[])
|
|
105
|
+
: [];
|
|
106
|
+
|
|
107
|
+
if (isSectionSelected(item)) {
|
|
108
|
+
selection.value = selectedItems.filter(
|
|
109
|
+
(opt) => !sectionItems.find((i) => i.value === opt),
|
|
110
|
+
);
|
|
111
|
+
} else {
|
|
112
|
+
selection.value = [
|
|
113
|
+
...selectedItems,
|
|
114
|
+
...sectionItems
|
|
115
|
+
.filter((opt) => !selectedItems.includes(opt.value!))
|
|
116
|
+
.map((i) => i.value!),
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function selectAll() {
|
|
122
|
+
selection.value = props.options
|
|
123
|
+
.filter(
|
|
124
|
+
(option) =>
|
|
125
|
+
option.value !== undefined &&
|
|
126
|
+
option.value !== null &&
|
|
127
|
+
!option.disabled &&
|
|
128
|
+
option.type !== 'section',
|
|
129
|
+
)
|
|
130
|
+
.map((item) => item.value!);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function clearSelection() {
|
|
134
|
+
selection.value = Array.isArray(selection.value) ? [] : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
selection,
|
|
139
|
+
sectionMap,
|
|
140
|
+
isSelectable,
|
|
141
|
+
isOptionSelected,
|
|
142
|
+
isSectionSelected,
|
|
143
|
+
isIndeterminate,
|
|
144
|
+
toggleValue,
|
|
145
|
+
toggleSection,
|
|
146
|
+
selectAll,
|
|
147
|
+
clearSelection,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -31,15 +31,29 @@ describe('MPageHeader', () => {
|
|
|
31
31
|
expect(wrapper.find('.slot-content').exists()).toBe(true);
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
it('renders
|
|
34
|
+
it('renders features slot when provided', () => {
|
|
35
35
|
const wrapper = shallowMount(MPageHeader, {
|
|
36
36
|
props: { title: 'My Page' },
|
|
37
37
|
slots: {
|
|
38
|
-
actions: '<button class="
|
|
38
|
+
actions: '<button class="feature-btn">Feature</button>',
|
|
39
39
|
},
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
expect(wrapper.find('.
|
|
42
|
+
expect(wrapper.find('.feature-btn').exists()).toBe(true);
|
|
43
|
+
expect(wrapper.find('.mc-page-header__features-wrapper').exists()).toBe(
|
|
44
|
+
true,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('renders context slot when provided', () => {
|
|
49
|
+
const wrapper = shallowMount(MPageHeader, {
|
|
50
|
+
props: { title: 'My Page' },
|
|
51
|
+
slots: {
|
|
52
|
+
context: '<div class="context-slot">Context</div>',
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(wrapper.find('.context-slot').exists()).toBe(true);
|
|
43
57
|
});
|
|
44
58
|
|
|
45
59
|
it('renders tabs slot when provided', () => {
|
|
@@ -2,13 +2,18 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
|
|
2
2
|
import { action } from 'storybook/actions';
|
|
3
3
|
import MPageHeader from './MPageHeader.vue';
|
|
4
4
|
import MTabs from '../tabs/MTabs.vue';
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import
|
|
5
|
+
import MPageHeaderFeature from '../pageheaderfeature/MPageHeaderFeature.vue';
|
|
6
|
+
import MPageHeaderContext from '../pageheadercontext/MPageHeaderContext.vue';
|
|
7
|
+
import { HelpCircle20, Search20, Notification20 } from '@mozaic-ds/icons-vue';
|
|
8
|
+
import { ref } from 'vue';
|
|
8
9
|
|
|
9
10
|
const meta: Meta<typeof MPageHeader> = {
|
|
10
11
|
title: 'Structure/Page Header',
|
|
11
12
|
component: MPageHeader,
|
|
13
|
+
subcomponents: {
|
|
14
|
+
MPageHeaderFeature,
|
|
15
|
+
MPageHeaderContext,
|
|
16
|
+
},
|
|
12
17
|
parameters: {
|
|
13
18
|
layout: 'fullscreen',
|
|
14
19
|
docs: {
|
|
@@ -26,61 +31,51 @@ const meta: Meta<typeof MPageHeader> = {
|
|
|
26
31
|
statusLabel: 'Information',
|
|
27
32
|
extraInfo: 'Additional information',
|
|
28
33
|
actions: `
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
<template #icon>
|
|
34
|
-
<Search24 />
|
|
35
|
-
</template>
|
|
36
|
-
</MIconButton>
|
|
37
|
-
<MIconButton
|
|
38
|
-
ghost
|
|
39
|
-
aria-label="Help button"
|
|
40
|
-
>
|
|
41
|
-
<template #icon>
|
|
42
|
-
<HelpCircle24 />
|
|
43
|
-
</template>
|
|
44
|
-
</MIconButton>
|
|
45
|
-
<MIconButton
|
|
46
|
-
ghost
|
|
47
|
-
aria-label="Notification button"
|
|
48
|
-
>
|
|
49
|
-
<template #icon>
|
|
50
|
-
<Notification24 />
|
|
51
|
-
</template>
|
|
52
|
-
</MIconButton>
|
|
53
|
-
<MSelect
|
|
54
|
-
id="scope-select"
|
|
55
|
-
aria-label="Scope select"
|
|
56
|
-
size="s"
|
|
57
|
-
placeholder="Choose a scope"
|
|
58
|
-
:options='[{ "text": "Option1", "value": "option1"}, { "text": "Option2", "value": "option2"}]'
|
|
34
|
+
<MPageHeaderFeature
|
|
35
|
+
label="Search"
|
|
36
|
+
:icon="Search20"
|
|
37
|
+
@action="handleFeatureClick"
|
|
59
38
|
/>
|
|
60
|
-
|
|
39
|
+
|
|
40
|
+
<MPageHeaderFeature
|
|
41
|
+
label="Help"
|
|
42
|
+
:icon="HelpCircle20"
|
|
43
|
+
@action="handleFeatureClick"
|
|
44
|
+
/>
|
|
45
|
+
|
|
46
|
+
<MPageHeaderFeature
|
|
47
|
+
label="Notifications"
|
|
48
|
+
:icon="Notification20"
|
|
49
|
+
@action="handleFeatureClick"
|
|
50
|
+
/>
|
|
51
|
+
`,
|
|
52
|
+
context: `<MPageHeaderContext v-model="context" :options="[{ label: 'Option 1', value: 1 }, { label: 'Option 2', value: 2 }]" />`,
|
|
61
53
|
tabs: `
|
|
62
|
-
<MTabs :tabs='[{ "label": "Label" }, { "label": "Label" }, { "label": "Label" }, { "label": "Label" }]' />
|
|
54
|
+
<MTabs size="s" :tabs='[{ "label": "Label" }, { "label": "Label" }, { "label": "Label" }, { "label": "Label" }]' />
|
|
63
55
|
`,
|
|
64
56
|
},
|
|
65
57
|
render: (args) => ({
|
|
66
58
|
components: {
|
|
67
59
|
MPageHeader,
|
|
68
60
|
MTabs,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
HelpCircle24,
|
|
72
|
-
Notification24,
|
|
73
|
-
MSelect,
|
|
61
|
+
MPageHeaderFeature,
|
|
62
|
+
MPageHeaderContext,
|
|
74
63
|
},
|
|
75
64
|
setup() {
|
|
76
65
|
const handleBackButtonClick = action('back');
|
|
77
66
|
const handleMenuClick = action('toggle-menu');
|
|
67
|
+
const handleFeatureClick = action('action');
|
|
68
|
+
const context = ref('');
|
|
78
69
|
|
|
79
70
|
return {
|
|
80
71
|
args,
|
|
81
|
-
|
|
72
|
+
context,
|
|
73
|
+
HelpCircle20,
|
|
74
|
+
Search20,
|
|
75
|
+
Notification20,
|
|
82
76
|
handleBackButtonClick,
|
|
83
77
|
handleMenuClick,
|
|
78
|
+
handleFeatureClick,
|
|
84
79
|
};
|
|
85
80
|
},
|
|
86
81
|
template: `
|
|
@@ -91,6 +86,7 @@ const meta: Meta<typeof MPageHeader> = {
|
|
|
91
86
|
>
|
|
92
87
|
<template v-if="${'tabs' in args}" v-slot:tabs>${args.tabs}</template>
|
|
93
88
|
<template v-if="${'actions' in args}" v-slot:actions>${args.actions}</template>
|
|
89
|
+
<template v-if="${'context' in args}" v-slot:context>${args.context}</template>
|
|
94
90
|
</MPageHeader>
|
|
95
91
|
`,
|
|
96
92
|
}),
|
|
@@ -7,27 +7,29 @@
|
|
|
7
7
|
>
|
|
8
8
|
<div class="mc-page-header__top-wrapper">
|
|
9
9
|
<div class="mc-page-header__top-content">
|
|
10
|
-
<MIconButton
|
|
11
|
-
v-if="backButton"
|
|
12
|
-
ghost
|
|
13
|
-
aria-label="Back button"
|
|
14
|
-
@click="emit('back')"
|
|
15
|
-
>
|
|
16
|
-
<template #icon>
|
|
17
|
-
<ArrowBack24 />
|
|
18
|
-
</template>
|
|
19
|
-
</MIconButton>
|
|
20
|
-
|
|
21
10
|
<div class="mc-page-header__content-wrapper">
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
11
|
+
<div class="mc-page-header__title-wrapper">
|
|
12
|
+
<MIconButton
|
|
13
|
+
v-if="backButton"
|
|
14
|
+
ghost
|
|
15
|
+
aria-label="Back button"
|
|
16
|
+
@click="emit('back')"
|
|
17
|
+
>
|
|
18
|
+
<template #icon>
|
|
19
|
+
<ArrowBack24 />
|
|
20
|
+
</template>
|
|
21
|
+
</MIconButton>
|
|
22
|
+
<span class="mc-page-header__title">
|
|
23
|
+
{{ title }}
|
|
24
|
+
</span>
|
|
25
|
+
</div>
|
|
25
26
|
|
|
26
27
|
<div v-if="status || extraInfo" class="mc-page-header__info-wrapper">
|
|
27
28
|
<MStatusBadge
|
|
28
29
|
v-if="statusLabel && status"
|
|
29
30
|
:label="statusLabel"
|
|
30
31
|
:status="status"
|
|
32
|
+
size="s"
|
|
31
33
|
/>
|
|
32
34
|
|
|
33
35
|
<span v-if="extraInfo" class="mc-page-header__extra-info">
|
|
@@ -52,7 +54,11 @@
|
|
|
52
54
|
</MIconButton>
|
|
53
55
|
|
|
54
56
|
<div class="mc-page-header__actions-content">
|
|
55
|
-
<
|
|
57
|
+
<div v-if="$slots.actions" class="mc-page-header__features-wrapper">
|
|
58
|
+
<slot name="actions" />
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<slot name="context" />
|
|
56
62
|
</div>
|
|
57
63
|
</div>
|
|
58
64
|
</div>
|
|
@@ -111,9 +117,13 @@ const emit = defineEmits<{
|
|
|
111
117
|
|
|
112
118
|
defineSlots<{
|
|
113
119
|
/**
|
|
114
|
-
* Use this slot to insert
|
|
120
|
+
* Use this slot to insert feature items in the top content.
|
|
115
121
|
*/
|
|
116
122
|
actions?: VNode;
|
|
123
|
+
/**
|
|
124
|
+
* Use this slot to insert context switcher component.
|
|
125
|
+
*/
|
|
126
|
+
context?: VNode;
|
|
117
127
|
/**
|
|
118
128
|
* Use this slot to insert any content below the Page Header top content.
|
|
119
129
|
*/
|
|
@@ -18,7 +18,8 @@ The Page Header is a fundamental component that structures the top part of an in
|
|
|
18
18
|
|
|
19
19
|
| Name | Description |
|
|
20
20
|
| --- | --- |
|
|
21
|
-
| `actions` | Use this slot to insert
|
|
21
|
+
| `actions` | Use this slot to insert feature items in the top content. |
|
|
22
|
+
| `context` | Use this slot to insert context switcher component. |
|
|
22
23
|
| `content` | Use this slot to insert any content below the Page Header top content. |
|
|
23
24
|
| `tabs` | Use this slot to insert a tab in the header. |
|
|
24
25
|
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils';
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { nextTick } from 'vue';
|
|
4
|
+
import MPageHeaderContext from './MPageHeaderContext.vue';
|
|
5
|
+
|
|
6
|
+
const options = [
|
|
7
|
+
{ label: 'Option 1', value: 1 },
|
|
8
|
+
{ label: 'Option 2', value: 2 },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const stubs = {
|
|
12
|
+
MOptionListbox: {
|
|
13
|
+
name: 'MOptionListbox',
|
|
14
|
+
props: ['modelValue', 'options', 'multiple', 'id'],
|
|
15
|
+
emits: ['update:modelValue'],
|
|
16
|
+
template: '<div class="option-listbox-stub" />',
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe('MPageHeaderContext', () => {
|
|
21
|
+
it('renders selected option label in single mode', () => {
|
|
22
|
+
const wrapper = mount(MPageHeaderContext, {
|
|
23
|
+
props: {
|
|
24
|
+
modelValue: 2,
|
|
25
|
+
options,
|
|
26
|
+
},
|
|
27
|
+
global: { stubs },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(wrapper.find('.mc-page-header__context-switcher').text()).toContain(
|
|
31
|
+
'Option 2',
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('falls back to provided label when no option matches modelValue', () => {
|
|
36
|
+
const wrapper = mount(MPageHeaderContext, {
|
|
37
|
+
props: {
|
|
38
|
+
modelValue: 999,
|
|
39
|
+
options,
|
|
40
|
+
label: 'Fallback context',
|
|
41
|
+
},
|
|
42
|
+
global: { stubs },
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(wrapper.find('.mc-page-header__context-switcher').text()).toContain(
|
|
46
|
+
'Fallback context',
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('always uses label in multiple mode', () => {
|
|
51
|
+
const wrapper = mount(MPageHeaderContext, {
|
|
52
|
+
props: {
|
|
53
|
+
modelValue: [1],
|
|
54
|
+
options,
|
|
55
|
+
multiple: true,
|
|
56
|
+
label: 'Multi context',
|
|
57
|
+
},
|
|
58
|
+
global: { stubs },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(wrapper.find('.mc-page-header__context-switcher').text()).toContain(
|
|
62
|
+
'Multi context',
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('toggles listbox visibility when switcher button is clicked', async () => {
|
|
67
|
+
const wrapper = mount(MPageHeaderContext, {
|
|
68
|
+
props: {
|
|
69
|
+
modelValue: null,
|
|
70
|
+
options,
|
|
71
|
+
},
|
|
72
|
+
global: { stubs },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const listbox = wrapper.find('.mc-page-header__context-switcher-listbox');
|
|
76
|
+
expect(listbox.classes()).toContain(
|
|
77
|
+
'mc-page-header__context-switcher-listbox--hidden',
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
await wrapper.find('.mc-page-header__context-switcher').trigger('click');
|
|
81
|
+
expect(listbox.classes()).not.toContain(
|
|
82
|
+
'mc-page-header__context-switcher-listbox--hidden',
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('emits update:modelValue when the listbox emits an update', async () => {
|
|
87
|
+
const wrapper = mount(MPageHeaderContext, {
|
|
88
|
+
props: {
|
|
89
|
+
modelValue: null,
|
|
90
|
+
options,
|
|
91
|
+
},
|
|
92
|
+
global: { stubs },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
wrapper
|
|
96
|
+
.findComponent({ name: 'MOptionListbox' })
|
|
97
|
+
.vm.$emit('update:modelValue', 1);
|
|
98
|
+
await nextTick();
|
|
99
|
+
|
|
100
|
+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([1]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('closes the listbox on outside click', async () => {
|
|
104
|
+
const wrapper = mount(MPageHeaderContext, {
|
|
105
|
+
props: {
|
|
106
|
+
modelValue: null,
|
|
107
|
+
options,
|
|
108
|
+
},
|
|
109
|
+
global: { stubs },
|
|
110
|
+
attachTo: document.body,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await wrapper.find('.mc-page-header__context-switcher').trigger('click');
|
|
114
|
+
expect(
|
|
115
|
+
wrapper
|
|
116
|
+
.find('.mc-page-header__context-switcher-listbox')
|
|
117
|
+
.classes()
|
|
118
|
+
.includes('mc-page-header__context-switcher-listbox--hidden'),
|
|
119
|
+
).toBe(false);
|
|
120
|
+
|
|
121
|
+
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
122
|
+
await nextTick();
|
|
123
|
+
|
|
124
|
+
expect(
|
|
125
|
+
wrapper
|
|
126
|
+
.find('.mc-page-header__context-switcher-listbox')
|
|
127
|
+
.classes()
|
|
128
|
+
.includes('mc-page-header__context-switcher-listbox--hidden'),
|
|
129
|
+
).toBe(true);
|
|
130
|
+
|
|
131
|
+
wrapper.unmount();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('registers and removes click listener on mount lifecycle', () => {
|
|
135
|
+
const addSpy = vi.spyOn(document, 'addEventListener');
|
|
136
|
+
const removeSpy = vi.spyOn(document, 'removeEventListener');
|
|
137
|
+
|
|
138
|
+
const wrapper = mount(MPageHeaderContext, {
|
|
139
|
+
props: {
|
|
140
|
+
modelValue: null,
|
|
141
|
+
options,
|
|
142
|
+
},
|
|
143
|
+
global: { stubs },
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(addSpy).toHaveBeenCalledWith('click', expect.any(Function));
|
|
147
|
+
|
|
148
|
+
wrapper.unmount();
|
|
149
|
+
|
|
150
|
+
expect(removeSpy).toHaveBeenCalledWith('click', expect.any(Function));
|
|
151
|
+
|
|
152
|
+
addSpy.mockRestore();
|
|
153
|
+
removeSpy.mockRestore();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="mc-page-header__context-switcher-wrapper">
|
|
3
|
+
<button
|
|
4
|
+
type="button"
|
|
5
|
+
class="mc-page-header__context-switcher"
|
|
6
|
+
:aria-expanded="isOpen ? 'true' : 'false'"
|
|
7
|
+
aria-haspopup="listbox"
|
|
8
|
+
aria-controls="listbox-contextSwitcherMenu"
|
|
9
|
+
@click="isOpen = !isOpen"
|
|
10
|
+
>
|
|
11
|
+
<span> {{ contextLabel }} </span>
|
|
12
|
+
<ChevronDown20 />
|
|
13
|
+
</button>
|
|
14
|
+
|
|
15
|
+
<div
|
|
16
|
+
:class="{
|
|
17
|
+
'mc-page-header__context-switcher-listbox': true,
|
|
18
|
+
'mc-page-header__context-switcher-listbox--hidden': !isOpen,
|
|
19
|
+
'mc-page-header__context-switcher-listbox--multiple': props.multiple,
|
|
20
|
+
}"
|
|
21
|
+
>
|
|
22
|
+
<MOptionListbox
|
|
23
|
+
id="contextSwitcherMenu"
|
|
24
|
+
v-model="model"
|
|
25
|
+
:options="props.options"
|
|
26
|
+
:multiple="props.multiple"
|
|
27
|
+
:open="isOpen"
|
|
28
|
+
@open="isOpen = true"
|
|
29
|
+
@close="isOpen = false"
|
|
30
|
+
/>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<script setup lang="ts">
|
|
36
|
+
import { ChevronDown20 } from '@mozaic-ds/icons-vue';
|
|
37
|
+
import MOptionListbox from '../optionListbox/MOptionListbox.vue';
|
|
38
|
+
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The Page Header Context component provides a compact context switcher integrated in the page header.
|
|
42
|
+
* It allows selecting a single context or multiple contexts from a list of options.
|
|
43
|
+
*/
|
|
44
|
+
const props = withDefaults(
|
|
45
|
+
defineProps<{
|
|
46
|
+
/**
|
|
47
|
+
* Current selected value.
|
|
48
|
+
*/
|
|
49
|
+
modelValue: string | number | null | (string | number)[];
|
|
50
|
+
/**
|
|
51
|
+
* Fallback label displayed when no option is selected, or main label in multiple mode.
|
|
52
|
+
*/
|
|
53
|
+
label?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Enables multiple selection mode.
|
|
56
|
+
*/
|
|
57
|
+
multiple?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Available options in the context switcher list.
|
|
60
|
+
*/
|
|
61
|
+
options: Array<{
|
|
62
|
+
/**
|
|
63
|
+
* Option label displayed in the list.
|
|
64
|
+
*/
|
|
65
|
+
label: string;
|
|
66
|
+
/**
|
|
67
|
+
* Option value emitted on selection.
|
|
68
|
+
*/
|
|
69
|
+
value: string | number;
|
|
70
|
+
}>;
|
|
71
|
+
}>(),
|
|
72
|
+
{
|
|
73
|
+
label: 'Default label',
|
|
74
|
+
multiple: false,
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const emit = defineEmits<{
|
|
79
|
+
/**
|
|
80
|
+
* Emits when the selected value changes.
|
|
81
|
+
*/
|
|
82
|
+
(
|
|
83
|
+
e: 'update:modelValue',
|
|
84
|
+
value: string | number | null | (string | number)[],
|
|
85
|
+
): void;
|
|
86
|
+
}>();
|
|
87
|
+
|
|
88
|
+
const isOpen = ref(false);
|
|
89
|
+
|
|
90
|
+
const model = computed({
|
|
91
|
+
get: () => props.modelValue,
|
|
92
|
+
set: (value: string | number | null | (string | number)[]) => {
|
|
93
|
+
emit('update:modelValue', value);
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const contextLabel = computed(() => {
|
|
98
|
+
if (props.multiple) {
|
|
99
|
+
return props.label;
|
|
100
|
+
} else {
|
|
101
|
+
const selectedOption = props.options.find(
|
|
102
|
+
(option) => option.value === props.modelValue,
|
|
103
|
+
);
|
|
104
|
+
return selectedOption ? selectedOption.label : props.label;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
109
|
+
const target = event.target as HTMLElement;
|
|
110
|
+
if (!target.closest('.mc-page-header__context-switcher-wrapper')) {
|
|
111
|
+
isOpen.value = false;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
onMounted(() => {
|
|
116
|
+
document.addEventListener('click', handleClickOutside);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
onBeforeUnmount(() => {
|
|
120
|
+
document.removeEventListener('click', handleClickOutside);
|
|
121
|
+
});
|
|
122
|
+
</script>
|
|
123
|
+
|
|
124
|
+
<style lang="scss" scoped>
|
|
125
|
+
@use '@mozaic-ds/styles/components/page-header';
|
|
126
|
+
</style>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# MPageHeaderContext
|
|
2
|
+
|
|
3
|
+
The Page Header Context component provides a compact context switcher integrated in the page header. It allows selecting a single context or multiple contexts from a list of options.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
## Props
|
|
7
|
+
|
|
8
|
+
| Name | Description | Type | Default |
|
|
9
|
+
| --- | --- | --- | --- |
|
|
10
|
+
| `modelValue*` | Current selected value. | `string` `number` `(string` `number)[]` `null` | - |
|
|
11
|
+
| `label` | Fallback label displayed when no option is selected, or main label in multiple mode. | `string` | `"Default label"` |
|
|
12
|
+
| `multiple` | Enables multiple selection mode. | `boolean` | `false` |
|
|
13
|
+
| `options*` | Available options in the context switcher list. | `{ label: string; value: string` `number; }[]` | - |
|
|
14
|
+
|
|
15
|
+
## Events
|
|
16
|
+
|
|
17
|
+
| Name | Description | Type |
|
|
18
|
+
| --- | --- | --- |
|
|
19
|
+
| `update:modelValue` | - | `[value: string` `number` `(string` `number)[]` `null]` |
|
|
20
|
+
|
|
21
|
+
## Dependencies
|
|
22
|
+
|
|
23
|
+
### Depends on
|
|
24
|
+
|
|
25
|
+
- [MOptionListbox](../optionListbox)
|
|
26
|
+
|
|
27
|
+
### Graph
|
|
28
|
+
|
|
29
|
+
```mermaid
|
|
30
|
+
graph TD;
|
|
31
|
+
MPageHeaderContext --> MOptionListbox
|
|
32
|
+
style MPageHeaderContext fill:#008240,stroke:#333,stroke-width:4px
|
|
33
|
+
```
|