@mozaic-ds/vue 2.19.1 → 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 +1952 -1678
  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 +6 -3
  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
@@ -39,7 +39,7 @@ import '@mozaic-ds/styles';
39
39
  <svg lass="mc-button__icon" width="24" height="24" aria-hidden="true" viewBox="0 0 54 80" fill="none"><g clip-path="url(#figma__clip0_912_3)"><path d="M13.3333 80.0002C20.6933 80.0002 26.6667 74.0268 26.6667 66.6668V53.3335H13.3333C5.97333 53.3335 0 59.3068 0 66.6668C0 74.0268 5.97333 80.0002 13.3333 80.0002Z" fill="#0ACF83"/><path d="M0 39.9998C0 32.6398 5.97333 26.6665 13.3333 26.6665H26.6667V53.3332H13.3333C5.97333 53.3332 0 47.3598 0 39.9998Z" fill="#A259FF"/><path d="M0 13.3333C0 5.97333 5.97333 0 13.3333 0H26.6667V26.6667H13.3333C5.97333 26.6667 0 20.6933 0 13.3333Z" fill="#F24E1E"/><path d="M26.6667 0H40.0001C47.3601 0 53.3334 5.97333 53.3334 13.3333C53.3334 20.6933 47.3601 26.6667 40.0001 26.6667H26.6667V0Z" fill="#FF7262"/><path d="M53.3334 39.9998C53.3334 47.3598 47.3601 53.3332 40.0001 53.3332C32.6401 53.3332 26.6667 47.3598 26.6667 39.9998C26.6667 32.6398 32.6401 26.6665 40.0001 26.6665C47.3601 26.6665 53.3334 32.6398 53.3334 39.9998Z" fill="#1ABCFE"/></g><defs><clipPath id="figma__clip0_912_3"><rect width="53.3333" height="80" fill="white"/></clipPath></defs></svg>
40
40
  <span class="mc-button__label">Figma</span>
41
41
  </a>
42
- <a href="?path=/docs/getting-started--docs" class="mc-button mc-button--accent">
42
+ <a href="/?path=/docs/getting-started--docs" class="mc-button mc-button--accent">
43
43
  <span class="mc-button__label">Get Started</span>
44
44
  <svg class="mc-button__icon" width="24" height="24" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
45
45
  <path fill-rule="evenodd" d="M15.2929 6.29289c.3905-.39052 1.0237-.39052 1.4142 0l5 5.00001c.3905.3905.3905 1.0237 0 1.4142l-5 5c-.3905.3905-1.0237.3905-1.4142 0-.3905-.3905-.3905-1.0237 0-1.4142L18.5858 13H3c-.55228 0-1-.4477-1-1s.44772-1 1-1h15.5858l-3.2929-3.29289c-.3905-.39053-.3905-1.02369 0-1.41422Z"></path>
@@ -1,28 +1,16 @@
1
1
  import { mount } from '@vue/test-utils';
2
- import {
3
- describe,
4
- it,
5
- expect,
6
- vi,
7
- beforeAll,
8
- afterAll,
9
- beforeEach,
10
- } from 'vitest';
2
+ import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
3
+ import { nextTick } from 'vue';
11
4
  import MCarousel from './MCarousel.vue';
12
5
  import MIconButton from '../iconbutton/MIconButton.vue';
13
6
  import { ChevronLeft20, ChevronRight20 } from '@mozaic-ds/icons-vue';
14
7
 
15
8
  /* eslint-disable @typescript-eslint/no-explicit-any */
16
9
 
17
- const observerInstances: MockIntersectionObserver[] = [];
18
-
19
- class MockIntersectionObserver {
10
+ class MockResizeObserver {
20
11
  callback: any;
21
- options: any;
22
- constructor(callback: any, options?: any) {
12
+ constructor(callback: any) {
23
13
  this.callback = callback;
24
- this.options = options;
25
- observerInstances.push(this);
26
14
  }
27
15
  observe = vi.fn();
28
16
  unobserve = vi.fn();
@@ -30,24 +18,33 @@ class MockIntersectionObserver {
30
18
  }
31
19
 
32
20
  describe('MCarousel component', () => {
33
- let originalObserver: any;
34
-
35
- beforeEach(() => {
36
- observerInstances.length = 0;
37
- });
21
+ let originalResizeObserver: any;
38
22
 
39
23
  beforeAll(() => {
40
- originalObserver = global.IntersectionObserver;
41
- global.IntersectionObserver = MockIntersectionObserver as any;
24
+ originalResizeObserver = global.ResizeObserver;
25
+ global.ResizeObserver = MockResizeObserver as any;
42
26
 
43
27
  Object.defineProperty(window.HTMLElement.prototype, 'scrollIntoView', {
44
28
  value: vi.fn(),
45
29
  writable: true,
46
30
  });
31
+
32
+ // Mock getBoundingClientRect for visibility tests
33
+ window.HTMLElement.prototype.getBoundingClientRect = vi.fn(() => ({
34
+ left: 0,
35
+ top: 0,
36
+ right: 100,
37
+ bottom: 100,
38
+ width: 100,
39
+ height: 100,
40
+ x: 0,
41
+ y: 0,
42
+ toJSON: () => {},
43
+ }));
47
44
  });
48
45
 
49
46
  afterAll(() => {
50
- global.IntersectionObserver = originalObserver;
47
+ global.ResizeObserver = originalResizeObserver;
51
48
  });
52
49
 
53
50
  const mockChildren = [
@@ -101,45 +98,70 @@ describe('MCarousel component', () => {
101
98
  expect(wrapper.findComponent(ChevronRight20).exists()).toBe(true);
102
99
  });
103
100
 
104
- it('disables the previous button when on the first slide', () => {
101
+ it('disables the previous button when on the first slide', async () => {
105
102
  const wrapper = mountCarousel();
103
+
104
+ // Mock container at start position
105
+ const contentContainer = wrapper.find('.mc-carousel__content');
106
+ Object.defineProperty(contentContainer.element, 'scrollLeft', {
107
+ value: 0,
108
+ writable: true,
109
+ configurable: true,
110
+ });
111
+ Object.defineProperty(contentContainer.element, 'scrollWidth', {
112
+ value: 300,
113
+ writable: true,
114
+ configurable: true,
115
+ });
116
+ Object.defineProperty(contentContainer.element, 'clientWidth', {
117
+ value: 100,
118
+ writable: true,
119
+ configurable: true,
120
+ });
121
+
122
+ // Trigger scroll to update state
123
+ await contentContainer.trigger('scroll');
124
+ await nextTick();
125
+
106
126
  const [prevButton] = wrapper.findAllComponents(MIconButton);
107
127
  expect(prevButton.props('disabled')).toBe(true);
108
128
  });
109
129
 
110
- it('scrolls to next slide when goNext is called', async () => {
111
- const scrollIntoViewMock = vi.fn();
112
- (window.HTMLElement.prototype.scrollIntoView as any) = scrollIntoViewMock;
130
+ it('enables next button when not on last slide', async () => {
131
+ const wrapper = mountCarousel({
132
+ slots: {
133
+ default:
134
+ '<div class="slide">Slide 1</div><div class="slide">Slide 2</div><div class="slide">Slide 3</div>',
135
+ header: '<h2 id="mc-carousel__title">Carousel Header</h2>',
136
+ },
137
+ });
113
138
 
114
- const wrapper = mountCarousel();
115
- const vm = wrapper.vm as any;
116
- vi.spyOn(vm, 'getCarouselChildren').mockReturnValue([
117
- { scrollIntoView: scrollIntoViewMock },
118
- { scrollIntoView: scrollIntoViewMock },
119
- { scrollIntoView: scrollIntoViewMock },
120
- ]);
121
-
122
- vm.goNext();
123
- expect(scrollIntoViewMock).toHaveBeenCalled();
124
- });
139
+ // Mock container with scrollable content (on first slide)
140
+ const contentContainer = wrapper.find('.mc-carousel__content');
141
+ Object.defineProperty(contentContainer.element, 'scrollLeft', {
142
+ value: 0,
143
+ writable: true,
144
+ configurable: true,
145
+ });
146
+ Object.defineProperty(contentContainer.element, 'scrollWidth', {
147
+ value: 300,
148
+ writable: true,
149
+ configurable: true,
150
+ });
151
+ Object.defineProperty(contentContainer.element, 'clientWidth', {
152
+ value: 100,
153
+ writable: true,
154
+ configurable: true,
155
+ });
125
156
 
126
- it('scrolls to previous slide when goPrevious is called', async () => {
127
- const scrollIntoViewMock = vi.fn();
128
- (window.HTMLElement.prototype.scrollIntoView as any) = scrollIntoViewMock;
157
+ // Trigger scroll to update state
158
+ await contentContainer.trigger('scroll');
159
+ await nextTick();
160
+ await new Promise((resolve) => setTimeout(resolve, 200));
129
161
 
130
- const wrapper = mountCarousel();
131
- const vm = wrapper.vm as any;
132
- vi.spyOn(vm, 'getCarouselChildren').mockReturnValue([
133
- { scrollIntoView: scrollIntoViewMock },
134
- { scrollIntoView: scrollIntoViewMock },
135
- { scrollIntoView: scrollIntoViewMock },
136
- ]);
137
-
138
- vm.goNext();
139
- vm.goNext();
140
- vm.goPrevious();
141
-
142
- expect(scrollIntoViewMock).toHaveBeenCalled();
162
+ const buttons = wrapper.findAllComponents(MIconButton);
163
+ const nextButton = buttons[1];
164
+ expect(nextButton.props('disabled')).toBe(false);
143
165
  });
144
166
 
145
167
  it('sets correct ARIA attributes on main container', () => {
@@ -158,4 +180,101 @@ describe('MCarousel component', () => {
158
180
  const content = wrapper.find('.mc-carousel__content');
159
181
  expect(content.attributes('aria-live')).toBe('polite');
160
182
  });
183
+
184
+ it('disables next button when there is only one child', async () => {
185
+ const wrapper = mountCarousel({
186
+ slots: {
187
+ default: '<div class="slide">Slide 1</div>',
188
+ header: '<h2 id="mc-carousel__title">Carousel Header</h2>',
189
+ },
190
+ });
191
+
192
+ // Wait for component to initialize
193
+ await nextTick();
194
+ await new Promise((resolve) => setTimeout(resolve, 100));
195
+
196
+ const buttons = wrapper.findAllComponents(MIconButton);
197
+ const nextButton = buttons[1];
198
+
199
+ // Next button should be disabled when there's only one child (which is the last)
200
+ expect(nextButton.props('disabled')).toBe(true);
201
+ });
202
+
203
+ it('enables next button when there are multiple children and not on last', async () => {
204
+ const wrapper = mountCarousel({
205
+ slots: {
206
+ default:
207
+ '<div class="slide">Slide 1</div><div class="slide">Slide 2</div>',
208
+ header: '<h2 id="mc-carousel__title">Carousel Header</h2>',
209
+ },
210
+ });
211
+
212
+ // Mock container to simulate being on first slide with more content to scroll
213
+ const contentContainer = wrapper.find('.mc-carousel__content');
214
+ Object.defineProperty(contentContainer.element, 'scrollLeft', {
215
+ value: 0,
216
+ writable: true,
217
+ configurable: true,
218
+ });
219
+ Object.defineProperty(contentContainer.element, 'scrollWidth', {
220
+ value: 200,
221
+ writable: true,
222
+ configurable: true,
223
+ });
224
+ Object.defineProperty(contentContainer.element, 'clientWidth', {
225
+ value: 100,
226
+ writable: true,
227
+ configurable: true,
228
+ });
229
+
230
+ // Trigger scroll to update state
231
+ await contentContainer.trigger('scroll');
232
+ await nextTick();
233
+ await new Promise((resolve) => setTimeout(resolve, 200));
234
+
235
+ const buttons = wrapper.findAllComponents(MIconButton);
236
+ const nextButton = buttons[1];
237
+
238
+ // Next button should be enabled when on first slide of two
239
+ expect(nextButton.props('disabled')).toBe(false);
240
+ });
241
+
242
+ it('disables next button when on last child even with remaining scroll space', async () => {
243
+ const wrapper = mountCarousel({
244
+ slots: {
245
+ default: '<div class="slide" style="width: 200px;">Wide Slide</div>',
246
+ header: '<h2 id="mc-carousel__title">Carousel Header</h2>',
247
+ },
248
+ });
249
+
250
+ // Mock container with a wide child that can still scroll but is the only/last child
251
+ const contentContainer = wrapper.find('.mc-carousel__content');
252
+ Object.defineProperty(contentContainer.element, 'scrollLeft', {
253
+ value: 50,
254
+ writable: true,
255
+ configurable: true,
256
+ });
257
+ Object.defineProperty(contentContainer.element, 'scrollWidth', {
258
+ value: 200,
259
+ writable: true,
260
+ configurable: true,
261
+ });
262
+ Object.defineProperty(contentContainer.element, 'clientWidth', {
263
+ value: 100,
264
+ writable: true,
265
+ configurable: true,
266
+ });
267
+
268
+ // Trigger scroll to update state
269
+ await contentContainer.trigger('scroll');
270
+ await nextTick();
271
+ await new Promise((resolve) => setTimeout(resolve, 200));
272
+
273
+ const buttons = wrapper.findAllComponents(MIconButton);
274
+ const nextButton = buttons[1];
275
+
276
+ // Should be disabled because we're on the last (only) child
277
+ // even though scrollLeft (50) < scrollWidth - clientWidth (100)
278
+ expect(nextButton.props('disabled')).toBe(true);
279
+ });
161
280
  });
@@ -51,7 +51,7 @@
51
51
  </template>
52
52
 
53
53
  <script setup lang="ts">
54
- import { computed, onMounted, ref, useId, type VNode } from 'vue';
54
+ import { computed, onMounted, onUnmounted, ref, useId, type VNode } from 'vue';
55
55
  import MIconButton from '../iconbutton/MIconButton.vue';
56
56
  import { ChevronLeft20, ChevronRight20 } from '@mozaic-ds/icons-vue';
57
57
  /**
@@ -88,62 +88,135 @@ defineSlots<{
88
88
  const titleId = useId();
89
89
  const activeIndex = ref<number>(0);
90
90
  const contentContainer = ref<HTMLElement | null>(null);
91
-
92
- let observer: IntersectionObserver;
91
+ const canScrollLeft = ref<boolean>(false);
92
+ const canScrollRight = ref<boolean>(false);
93
93
 
94
94
  const scrollOptions: ScrollIntoViewOptions = {
95
95
  behavior: 'smooth',
96
96
  block: 'nearest',
97
- inline: 'center',
97
+ inline: 'start',
98
98
  };
99
99
 
100
- function getCarouselChildren() {
100
+ function getCarouselChildren(): Element[] {
101
101
  return contentContainer.value ? [...contentContainer.value.children] : [];
102
102
  }
103
103
 
104
- onMounted(() => {
105
- observer = new IntersectionObserver(
106
- (entries: IntersectionObserverEntry[]) => {
107
- const entry = entries.find(
108
- (e: IntersectionObserverEntry) => e.isIntersecting,
109
- );
110
- if (entry) {
111
- activeIndex.value = getCarouselChildren().findIndex(
112
- (e) => e === entry.target,
113
- );
114
- }
115
- },
116
- {
117
- root: contentContainer.value,
118
- threshold: 0.9,
119
- },
120
- );
121
-
122
- getCarouselChildren().forEach((el) => {
123
- observer.observe(el);
104
+ function updateScrollCapabilities() {
105
+ if (!contentContainer.value) return;
106
+
107
+ const container = contentContainer.value;
108
+ const scrollLeft = container.scrollLeft;
109
+ const scrollWidth = container.scrollWidth;
110
+ const clientWidth = container.clientWidth;
111
+
112
+ // Tolerance of 1px to handle rounding errors
113
+ const tolerance = 1;
114
+
115
+ canScrollLeft.value = scrollLeft > tolerance;
116
+
117
+ const children = getCarouselChildren();
118
+ const isLastChildActive = activeIndex.value === children.length - 1;
119
+
120
+ canScrollRight.value =
121
+ !isLastChildActive && scrollLeft < scrollWidth - clientWidth - tolerance;
122
+ }
123
+
124
+ function updateActiveIndex() {
125
+ if (!contentContainer.value) return;
126
+
127
+ const container = contentContainer.value;
128
+ const children = getCarouselChildren();
129
+
130
+ if (children.length === 0) return;
131
+
132
+ const containerRect = container.getBoundingClientRect();
133
+ const containerLeft = containerRect.left;
134
+
135
+ let closestIndex = 0;
136
+ let minDistance = Infinity;
137
+
138
+ children.forEach((child, index) => {
139
+ const childRect = child.getBoundingClientRect();
140
+ const distance = Math.abs(childRect.left - containerLeft);
141
+
142
+ if (distance < minDistance) {
143
+ minDistance = distance;
144
+ closestIndex = index;
145
+ }
124
146
  });
125
- });
147
+
148
+ activeIndex.value = closestIndex;
149
+ }
150
+
151
+ let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
152
+ function handleScroll() {
153
+ updateScrollCapabilities();
154
+
155
+ // Debounce for updateActiveIndex (more expensive)
156
+ if (scrollTimeout) {
157
+ clearTimeout(scrollTimeout);
158
+ }
159
+ scrollTimeout = setTimeout(() => {
160
+ updateActiveIndex();
161
+ }, 150);
162
+ }
126
163
 
127
164
  function scrollToChild(index: number) {
128
- getCarouselChildren()[index].scrollIntoView(scrollOptions);
165
+ const children = getCarouselChildren();
166
+ if (index >= 0 && index < children.length) {
167
+ children[index].scrollIntoView(scrollOptions);
168
+ activeIndex.value = index;
169
+ }
129
170
  }
130
171
 
131
172
  function goPrevious() {
132
- if (activeIndex.value > 0) {
173
+ if (canScrollLeft.value && activeIndex.value > 0) {
133
174
  scrollToChild(activeIndex.value - 1);
134
175
  }
135
176
  }
136
177
 
137
178
  function goNext() {
138
- if (activeIndex.value < getCarouselChildren().length - 1) {
179
+ const children = getCarouselChildren();
180
+ if (canScrollRight.value && activeIndex.value < children.length - 1) {
139
181
  scrollToChild(activeIndex.value + 1);
140
182
  }
141
183
  }
142
184
 
143
- const isFirstChildActive = computed(() => activeIndex.value === 0);
144
- const isLastChildActive = computed(
145
- () => activeIndex.value === getCarouselChildren().length - 1,
146
- );
185
+ const isFirstChildActive = computed(() => !canScrollLeft.value);
186
+ const isLastChildActive = computed(() => !canScrollRight.value);
187
+
188
+ onMounted(() => {
189
+ if (contentContainer.value) {
190
+ contentContainer.value.addEventListener('scroll', handleScroll, {
191
+ passive: true,
192
+ });
193
+
194
+ updateScrollCapabilities();
195
+ updateActiveIndex();
196
+
197
+ const resizeObserver = new ResizeObserver(() => {
198
+ updateScrollCapabilities();
199
+ updateActiveIndex();
200
+ });
201
+ resizeObserver.observe(contentContainer.value);
202
+
203
+ onUnmounted(() => {
204
+ resizeObserver.disconnect();
205
+ if (scrollTimeout) {
206
+ clearTimeout(scrollTimeout);
207
+ }
208
+ });
209
+ }
210
+ });
211
+
212
+ onUnmounted(() => {
213
+ if (contentContainer.value) {
214
+ contentContainer.value.removeEventListener('scroll', handleScroll);
215
+ }
216
+ if (scrollTimeout) {
217
+ clearTimeout(scrollTimeout);
218
+ }
219
+ });
147
220
  </script>
148
221
 
149
222
  <style scoped lang="scss">
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="mc-field__container" :class="classObjectContainer">
2
+ <div class="mc-field__content" :class="classObjectContainer">
3
3
  <MCheckbox
4
4
  v-for="option in options"
5
5
  :id="option.id"
@@ -8,7 +8,6 @@
8
8
  :is-invalid="option.isInvalid"
9
9
  :name="name"
10
10
  class="mc-field__item"
11
- :class="classObjectItem"
12
11
  :model-value="modelValue ? modelValue.includes(option.value) : undefined"
13
12
  :disabled="option.disabled"
14
13
  :indented="option.indented"
@@ -76,13 +75,7 @@ const onChange = (isChecked: boolean, value: string) => {
76
75
 
77
76
  const classObjectContainer = computed(() => {
78
77
  return {
79
- 'mc-field__container--inline': props.inline,
80
- };
81
- });
82
-
83
- const classObjectItem = computed(() => {
84
- return {
85
- 'mc-field__container--inline__item': props.inline,
78
+ 'mc-field__content--inline': props.inline,
86
79
  };
87
80
  });
88
81
 
@@ -217,6 +217,150 @@ describe('MCombobox', () => {
217
217
  expect(attr.includes('-2')).toBe(true);
218
218
  });
219
219
 
220
+ describe('accessibility attributes', () => {
221
+ function mountCombobox(props = {}) {
222
+ return mount(MCombobox, {
223
+ props: { modelValue: null, options, ...props },
224
+ global: {
225
+ components: {
226
+ MOptionListbox: MOptionListboxStub,
227
+ CrossCircleFilled24,
228
+ ChevronDown24,
229
+ },
230
+ },
231
+ });
232
+ }
233
+
234
+ it('control button has aria-expanded="false" when closed', () => {
235
+ const wrapper = mountCombobox();
236
+ const control = wrapper.find('.mc-combobox__control');
237
+ expect(control.attributes('aria-expanded')).toBe('false');
238
+ expect(control.attributes('aria-haspopup')).toBe('listbox');
239
+ });
240
+
241
+ it('control button has default aria-label "Combobox input"', () => {
242
+ const wrapper = mountCombobox();
243
+ expect(
244
+ wrapper.find('.mc-combobox__control').attributes('aria-label'),
245
+ ).toBe('Combobox input');
246
+ });
247
+
248
+ it('control button uses controlAriaLabel prop when provided', () => {
249
+ const wrapper = mountCombobox({ controlAriaLabel: 'Select a country' });
250
+ expect(
251
+ wrapper.find('.mc-combobox__control').attributes('aria-label'),
252
+ ).toBe('Select a country');
253
+ });
254
+
255
+ it('expand icon button has tabindex="-1" and aria-hidden="true"', () => {
256
+ const wrapper = mountCombobox();
257
+ const icon = wrapper.find('.mc-combobox__icon');
258
+ expect(icon.attributes('tabindex')).toBe('-1');
259
+ expect(icon.attributes('aria-hidden')).toBe('true');
260
+ });
261
+ });
262
+
263
+ describe('open prop (controlled mode)', () => {
264
+ function mountControlled(open: boolean) {
265
+ return mount(MCombobox, {
266
+ props: { modelValue: null, options, open },
267
+ global: {
268
+ components: {
269
+ MOptionListbox: MOptionListboxStub,
270
+ MTag: MTagStub,
271
+ MButton: MButtonStub,
272
+ CrossCircleFilled24,
273
+ ChevronDown24,
274
+ },
275
+ },
276
+ });
277
+ }
278
+
279
+ it('renders open when open prop is true', () => {
280
+ const wrapper = mountControlled(true);
281
+ expect(wrapper.find('.mc-combobox').classes()).toContain(
282
+ 'mc-combobox--open',
283
+ );
284
+ });
285
+
286
+ it('renders closed when open prop is false', () => {
287
+ const wrapper = mountControlled(false);
288
+ expect(wrapper.find('.mc-combobox').classes()).not.toContain(
289
+ 'mc-combobox--open',
290
+ );
291
+ });
292
+
293
+ it('emits update:open with toggled value on click without changing visual state', async () => {
294
+ const wrapper = mountControlled(false);
295
+ await wrapper.find('.mc-combobox__control').trigger('click');
296
+ expect(wrapper.emitted('update:open')?.[0]).toEqual([true]);
297
+ expect(wrapper.find('.mc-combobox').classes()).not.toContain(
298
+ 'mc-combobox--open',
299
+ );
300
+
301
+ const wrapper2 = mountControlled(true);
302
+ await wrapper2.find('.mc-combobox__control').trigger('click');
303
+ expect(wrapper2.emitted('update:open')?.[0]).toEqual([false]);
304
+ expect(wrapper2.find('.mc-combobox').classes()).toContain(
305
+ 'mc-combobox--open',
306
+ );
307
+ });
308
+
309
+ it('reflects open prop changes from parent', async () => {
310
+ const wrapper = mountControlled(false);
311
+ expect(wrapper.find('.mc-combobox').classes()).not.toContain(
312
+ 'mc-combobox--open',
313
+ );
314
+
315
+ await wrapper.setProps({ open: true });
316
+ expect(wrapper.find('.mc-combobox').classes()).toContain(
317
+ 'mc-combobox--open',
318
+ );
319
+
320
+ await wrapper.setProps({ open: false });
321
+ expect(wrapper.find('.mc-combobox').classes()).not.toContain(
322
+ 'mc-combobox--open',
323
+ );
324
+ });
325
+
326
+ it('opens and closes correctly with v-model:open', async () => {
327
+ const Parent = defineComponent({
328
+ components: { MCombobox },
329
+ setup() {
330
+ const open = ref(false);
331
+ return { open, options };
332
+ },
333
+ template: `<MCombobox v-model:open="open" :model-value="null" :options="options" />`,
334
+ });
335
+
336
+ const wrapper = mount(Parent, {
337
+ global: {
338
+ components: {
339
+ MOptionListbox: MOptionListboxStub,
340
+ MTag: MTagStub,
341
+ MButton: MButtonStub,
342
+ CrossCircleFilled24,
343
+ ChevronDown24,
344
+ },
345
+ },
346
+ });
347
+
348
+ expect(wrapper.find('.mc-combobox').classes()).not.toContain(
349
+ 'mc-combobox--open',
350
+ );
351
+
352
+ await wrapper.find('.mc-combobox__control').trigger('click');
353
+ expect(wrapper.find('.mc-combobox').classes()).toContain(
354
+ 'mc-combobox--open',
355
+ );
356
+
357
+ await wrapper.find('.mc-combobox__control').trigger('click');
358
+ expect(wrapper.find('.mc-combobox').classes()).not.toContain(
359
+ 'mc-combobox--open',
360
+ );
361
+ });
362
+ });
363
+
220
364
  it('clicking outside closes the listbox', async () => {
221
365
  const wrapper = mount(MCombobox, {
222
366
  props: { modelValue: null, options },
@@ -46,6 +46,7 @@ const meta: Meta<typeof MCombobox> = {
46
46
  },
47
47
  },
48
48
  args: {
49
+ open: false,
49
50
  modelValue: null,
50
51
  checkableSections: true,
51
52
  options: defaultOptions,
@@ -59,7 +60,7 @@ const meta: Meta<typeof MCombobox> = {
59
60
  return { args, counterLabel };
60
61
  },
61
62
  template: `
62
- <MCombobox v-bind="args" v-model="args.modelValue" :counter-label="counterLabel"></MCombobox>
63
+ <MCombobox v-bind="args" v-model="args.modelValue" v-model:open="args.open" :counter-label="counterLabel"></MCombobox>
63
64
  `,
64
65
  }),
65
66
  };