@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.
Files changed (88) hide show
  1. package/dist/mozaic-vue.css +1 -1
  2. package/dist/mozaic-vue.d.ts +512 -296
  3. package/dist/mozaic-vue.js +2005 -1758
  4. package/dist/mozaic-vue.js.map +1 -1
  5. package/dist/mozaic-vue.umd.cjs +6 -6
  6. package/dist/mozaic-vue.umd.cjs.map +1 -1
  7. package/package.json +5 -2
  8. package/src/components/Catalog.scss +84 -0
  9. package/src/components/Catalog.stories.ts +146 -0
  10. package/src/components/GettingStarted.mdx +258 -2
  11. package/src/components/Migration.mdx +4135 -532
  12. package/src/components/Welcome.mdx +1 -1
  13. package/src/components/carousel/MCarousel.spec.ts +174 -55
  14. package/src/components/carousel/MCarousel.vue +106 -33
  15. package/src/components/checkboxgroup/MCheckboxGroup.vue +2 -9
  16. package/src/components/combobox/MCombobox.spec.ts +144 -0
  17. package/src/components/combobox/MCombobox.stories.ts +2 -1
  18. package/src/components/combobox/MCombobox.vue +68 -38
  19. package/src/components/combobox/README.md +6 -0
  20. package/src/components/datatable/DataTable.stories.ts +2 -2
  21. package/src/components/datatable/DataTableCells.stories.ts +1 -0
  22. package/src/components/datatable/DataTableEmpty.stories.ts +1 -0
  23. package/src/components/datatable/DataTableExpandable.stories.ts +1 -0
  24. package/src/components/datatable/DataTableNested.stories.ts +1 -0
  25. package/src/components/datatable/DataTablePageable.stories.ts +194 -0
  26. package/src/components/datatable/DataTableSelectable.stories.ts +1 -0
  27. package/src/components/datatable/DataTableSortable.stories.ts +1 -0
  28. package/src/components/datepicker/MDatepicker.stories.ts +2 -0
  29. package/src/components/datepicker/MDatepicker.vue +17 -11
  30. package/src/components/divider/README.md +2 -0
  31. package/src/components/drawer/MDrawer.vue +7 -5
  32. package/src/components/link/MLink.stories.ts +18 -0
  33. package/src/components/link/MLink.vue +20 -4
  34. package/src/components/link/README.md +2 -1
  35. package/src/components/optionListbox/MOptionListbox.spec.ts +78 -1
  36. package/src/components/optionListbox/MOptionListbox.types.ts +7 -0
  37. package/src/components/optionListbox/MOptionListbox.vue +31 -217
  38. package/src/components/optionListbox/README.md +2 -0
  39. package/src/components/optionListbox/use-listbox-navigation.composable.ts +147 -0
  40. package/src/components/optionListbox/use-listbox-selection.composable.ts +149 -0
  41. package/src/components/pageheader/MPageHeader.spec.ts +17 -3
  42. package/src/components/pageheader/MPageHeader.stories.ts +37 -41
  43. package/src/components/pageheader/MPageHeader.vue +26 -16
  44. package/src/components/pageheader/README.md +2 -1
  45. package/src/components/pageheadercontext/MPageHeaderContext.spec.ts +155 -0
  46. package/src/components/pageheadercontext/MPageHeaderContext.vue +126 -0
  47. package/src/components/pageheadercontext/README.md +33 -0
  48. package/src/components/pageheaderfeature/MPageHeaderFeature.spec.ts +39 -0
  49. package/src/components/pageheaderfeature/MPageHeaderFeature.vue +40 -0
  50. package/src/components/pageheaderfeature/README.md +17 -0
  51. package/src/components/radiogroup/MRadioGroup.stories.ts +6 -0
  52. package/src/components/radiogroup/MRadioGroup.vue +2 -9
  53. package/src/components/sidebar/MSidebar.const.ts +2 -0
  54. package/src/components/sidebar/MSidebar.stories.ts +33 -0
  55. package/src/components/sidebar/MSidebar.vue +10 -1
  56. package/src/components/sidebar/README.md +1 -0
  57. package/src/components/sidebar/stories/DefaultCase.stories.vue +12 -11
  58. package/src/components/sidebar/use-floating-item.composable.ts +1 -1
  59. package/src/components/sidebarexpandableitem/MSidebarExpandableItem.vue +13 -7
  60. package/src/components/sidebarexpandableitem/README.md +2 -1
  61. package/src/components/sidebarfooter/MSidebarFooter.vue +16 -9
  62. package/src/components/sidebarfooter/_MSidebarFooterMenu.vue +10 -2
  63. package/src/components/sidebarnavitem/MSidebarNavItem.vue +36 -5
  64. package/src/components/sidebarnavitem/README.md +1 -0
  65. package/src/components/sidebarshortcutitem/MSidebarShortcutItem.vue +24 -3
  66. package/src/components/sidebarshortcutitem/README.md +3 -1
  67. package/src/components/sidebarshortcuts/MSidebarShortcuts.vue +6 -1
  68. package/src/components/sidebarshortcuts/README.md +12 -0
  69. package/src/components/statusbadge/MStatusBadge.stories.ts +5 -1
  70. package/src/components/statusbadge/MStatusBadge.vue +7 -1
  71. package/src/components/statusbadge/MstatusBadge.spec.ts +31 -0
  72. package/src/components/statusbadge/README.md +1 -0
  73. package/src/components/statusmessage/MStatusMessage.spec.ts +4 -4
  74. package/src/components/statusmessage/MStatusMessage.vue +5 -10
  75. package/src/components/statusnotification/MStatusNotification.spec.ts +9 -9
  76. package/src/components/statusnotification/MStatusNotification.vue +9 -8
  77. package/src/components/tabs/MTabs.stories.ts +30 -1
  78. package/src/components/tabs/MTabs.vue +12 -3
  79. package/src/components/tabs/Mtabs.spec.ts +19 -0
  80. package/src/components/tabs/README.md +1 -0
  81. package/src/components/tileselectable/MTileSelectable.spec.ts +69 -0
  82. package/src/components/tileselectable/MTileSelectable.stories.ts +8 -2
  83. package/src/components/tileselectable/MTileSelectable.vue +18 -1
  84. package/src/components/tileselectable/README.md +1 -0
  85. package/src/main.ts +3 -0
  86. package/src/utils/debounce.ts +15 -0
  87. package/src/components/BrandPresets.mdx +0 -146
  88. 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 actions slot when provided', () => {
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="action-btn">Action</button>',
38
+ actions: '<button class="feature-btn">Feature</button>',
39
39
  },
40
40
  });
41
41
 
42
- expect(wrapper.find('.action-btn').exists()).toBe(true);
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 MIconButton from '../iconbutton/MIconButton.vue';
6
- import { Search24, HelpCircle24, Notification24 } from '@mozaic-ds/icons-vue';
7
- import MSelect from '../select/MSelect.vue';
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
- <MIconButton
30
- ghost
31
- aria-label="Search button"
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
- MIconButton,
70
- Search24,
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
- <span class="mc-page-header__title">
23
- {{ title }}
24
- </span>
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
- <slot name="actions" />
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 action items in the top content.
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 action items in the top content. |
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
+ ```