@mozaic-ds/vue 2.17.0 → 2.18.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 (33) hide show
  1. package/dist/mozaic-vue.css +1 -1
  2. package/dist/mozaic-vue.d.ts +133 -55
  3. package/dist/mozaic-vue.js +1533 -4663
  4. package/dist/mozaic-vue.js.map +1 -1
  5. package/dist/mozaic-vue.umd.cjs +6 -25
  6. package/dist/mozaic-vue.umd.cjs.map +1 -1
  7. package/package.json +14 -8
  8. package/src/components/Migration.mdx +651 -0
  9. package/src/components/accordionlistitem/MAccordionListItem.spec.ts +22 -3
  10. package/src/components/accordionlistitem/MAccordionListItem.vue +38 -28
  11. package/src/components/builtinmenu/MBuiltInMenu.spec.ts +30 -1
  12. package/src/components/builtinmenu/MBuiltInMenu.vue +26 -17
  13. package/src/components/builtinmenu/README.md +2 -0
  14. package/src/components/callout/MCallout.spec.ts +35 -0
  15. package/src/components/callout/MCallout.vue +22 -4
  16. package/src/components/callout/README.md +2 -0
  17. package/src/components/checklistmenu/MCheckListMenu.spec.ts +12 -1
  18. package/src/components/checklistmenu/MCheckListMenu.vue +6 -0
  19. package/src/components/checklistmenu/README.md +2 -0
  20. package/src/components/datatable/datatable.mdx +3 -2
  21. package/src/components/navigationindicator/MNavigationIndicator.spec.ts +75 -18
  22. package/src/components/navigationindicator/MNavigationIndicator.vue +10 -12
  23. package/src/components/optionListbox/MOptionListbox.vue +16 -1
  24. package/src/components/popover/MPopover.spec.ts +126 -0
  25. package/src/components/popover/MPopover.vue +36 -1
  26. package/src/components/segmentedcontrol/MSegmentedControl.spec.ts +92 -0
  27. package/src/components/segmentedcontrol/MSegmentedControl.vue +61 -2
  28. package/src/components/starrating/MStarRating.spec.ts +19 -22
  29. package/src/components/starrating/MStarRating.vue +3 -2
  30. package/src/components/tabs/MTabs.vue +90 -4
  31. package/src/components/tabs/Mtabs.spec.ts +162 -0
  32. package/src/main.ts +1 -0
  33. package/src/components/ComponentsMapping.mdx +0 -98
@@ -103,4 +103,130 @@ describe('MPopover.vue', () => {
103
103
  expect(activator.exists()).toBe(true);
104
104
  expect(activator.attributes('popovertarget')).toBe(id);
105
105
  });
106
+
107
+ describe('Focus management (toggle)', () => {
108
+ it('focuses the first focusable element inside when opened', async () => {
109
+ const wrapper = mount(MPopover, {
110
+ attachTo: document.body,
111
+ props: { closable: true },
112
+ });
113
+
114
+ const popoverEl = wrapper.find('.mc-popover__wrapper').element;
115
+ const toggleEvent = new Event('toggle') as ToggleEvent;
116
+ Object.defineProperty(toggleEvent, 'newState', { value: 'open' });
117
+ popoverEl.dispatchEvent(toggleEvent);
118
+ await wrapper.vm.$nextTick();
119
+
120
+ const firstFocusable = popoverEl.querySelector<HTMLElement>(
121
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
122
+ );
123
+ expect(document.activeElement).toBe(firstFocusable);
124
+ wrapper.unmount();
125
+ });
126
+
127
+ it('focuses the popover container when opened without focusable content', async () => {
128
+ const wrapper = mount(MPopover, {
129
+ attachTo: document.body,
130
+ props: {
131
+ closable: false,
132
+ title: 'Information',
133
+ description: 'Read-only content',
134
+ },
135
+ });
136
+
137
+ const popoverEl = wrapper.find('.mc-popover__wrapper')
138
+ .element as HTMLElement;
139
+ const toggleEvent = new Event('toggle') as ToggleEvent;
140
+ Object.defineProperty(toggleEvent, 'newState', { value: 'open' });
141
+ popoverEl.dispatchEvent(toggleEvent);
142
+ await wrapper.vm.$nextTick();
143
+
144
+ expect(document.activeElement).toBe(popoverEl);
145
+ wrapper.unmount();
146
+ });
147
+
148
+ it('returns focus to the trigger when closed with focus inside the popover', async () => {
149
+ const wrapper = mount(MPopover, {
150
+ attachTo: document.body,
151
+ props: { closable: true },
152
+ slots: {
153
+ activator: (slotProps: { id: string }) =>
154
+ h('button', {
155
+ popovertarget: slotProps.id,
156
+ class: 'trigger-btn',
157
+ }),
158
+ },
159
+ });
160
+
161
+ // Simulate focus being on the close button (inside the popover)
162
+ const closeBtn = wrapper.findComponent(MIconButton).find('button')
163
+ .element as HTMLElement;
164
+ closeBtn.focus();
165
+
166
+ const popoverEl = wrapper.find('.mc-popover__wrapper').element;
167
+ const toggleEvent = new Event('toggle') as ToggleEvent;
168
+ Object.defineProperty(toggleEvent, 'newState', { value: 'closed' });
169
+ popoverEl.dispatchEvent(toggleEvent);
170
+ await wrapper.vm.$nextTick();
171
+
172
+ const trigger = wrapper.find('.trigger-btn').element as HTMLElement;
173
+ expect(document.activeElement).toBe(trigger);
174
+ wrapper.unmount();
175
+ });
176
+
177
+ it('returns focus to the trigger when closed with focus on document.body', async () => {
178
+ const wrapper = mount(MPopover, {
179
+ attachTo: document.body,
180
+ slots: {
181
+ activator: (slotProps: { id: string }) =>
182
+ h('button', {
183
+ popovertarget: slotProps.id,
184
+ class: 'trigger-btn',
185
+ }),
186
+ },
187
+ });
188
+
189
+ // Simulate browser moving focus to body after popover collapses
190
+ (document.activeElement as HTMLElement)?.blur?.();
191
+
192
+ const popoverEl = wrapper.find('.mc-popover__wrapper').element;
193
+ const toggleEvent = new Event('toggle') as ToggleEvent;
194
+ Object.defineProperty(toggleEvent, 'newState', { value: 'closed' });
195
+ popoverEl.dispatchEvent(toggleEvent);
196
+ await wrapper.vm.$nextTick();
197
+
198
+ const trigger = wrapper.find('.trigger-btn').element as HTMLElement;
199
+ expect(document.activeElement).toBe(trigger);
200
+ wrapper.unmount();
201
+ });
202
+
203
+ it('does not steal focus when closed by an outside click (focus already on another element)', async () => {
204
+ const wrapper = mount(MPopover, {
205
+ attachTo: document.body,
206
+ slots: {
207
+ activator: (slotProps: { id: string }) =>
208
+ h('button', {
209
+ popovertarget: slotProps.id,
210
+ class: 'trigger-btn',
211
+ }),
212
+ },
213
+ });
214
+
215
+ // Simulate user clicking an outside input
216
+ const outsideInput = document.createElement('input');
217
+ document.body.appendChild(outsideInput);
218
+ outsideInput.focus();
219
+
220
+ const popoverEl = wrapper.find('.mc-popover__wrapper').element;
221
+ const toggleEvent = new Event('toggle') as ToggleEvent;
222
+ Object.defineProperty(toggleEvent, 'newState', { value: 'closed' });
223
+ popoverEl.dispatchEvent(toggleEvent);
224
+ await wrapper.vm.$nextTick();
225
+
226
+ // Focus should remain on the outside input, not be stolen by the trigger
227
+ expect(document.activeElement).toBe(outsideInput);
228
+ outsideInput.remove();
229
+ wrapper.unmount();
230
+ });
231
+ });
106
232
  });
@@ -10,10 +10,13 @@
10
10
  >
11
11
  <div
12
12
  :id="id"
13
+ ref="popoverRef"
13
14
  class="mc-popover__wrapper"
14
15
  popover
16
+ tabindex="-1"
15
17
  :aria-labelledby="title && `${id}-title`"
16
18
  :aria-describedby="description && `${id}-description`"
19
+ @toggle="onToggle"
17
20
  >
18
21
  <div class="mc-popover__content">
19
22
  <div v-if="title || description" class="mc-popover__headings">
@@ -58,7 +61,7 @@
58
61
  </template>
59
62
 
60
63
  <script setup lang="ts">
61
- import { useId, type VNode } from 'vue';
64
+ import { useId, useTemplateRef, nextTick, type VNode } from 'vue';
62
65
  import { Cross20 } from '@mozaic-ds/icons-vue';
63
66
  import MIconButton from '../iconbutton/MIconButton.vue';
64
67
  /**
@@ -124,6 +127,38 @@ defineSlots<{
124
127
  }>();
125
128
 
126
129
  const id = useId();
130
+ const popoverRef = useTemplateRef('popoverRef');
131
+
132
+ function onToggle(event: ToggleEvent) {
133
+ if (event.newState === 'open') {
134
+ nextTick(() => {
135
+ const focusable = popoverRef.value?.querySelector<HTMLElement>(
136
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
137
+ );
138
+ if (focusable) {
139
+ focusable.focus();
140
+ } else {
141
+ popoverRef.value?.focus();
142
+ }
143
+ });
144
+ } else {
145
+ const activeEl = document.activeElement as HTMLElement | null;
146
+ const closedFromInside =
147
+ !activeEl ||
148
+ activeEl === document.body ||
149
+ popoverRef.value?.contains(activeEl);
150
+
151
+ if (closedFromInside) {
152
+ const triggers = document.querySelectorAll<HTMLElement>(
153
+ `[popovertarget="${id}"]:not(.mc-popover__close)`,
154
+ );
155
+ const trigger = Array.from(triggers).find(
156
+ (el) => !popoverRef.value?.contains(el),
157
+ );
158
+ trigger?.focus();
159
+ }
160
+ }
161
+ }
127
162
  </script>
128
163
 
129
164
  <style lang="scss" scoped>
@@ -1,5 +1,6 @@
1
1
  import { mount } from '@vue/test-utils';
2
2
  import { describe, it, expect } from 'vitest';
3
+ import { nextTick } from 'vue';
3
4
  import MSegmentedControl from './MSegmentedControl.vue';
4
5
 
5
6
  describe('MSegmentedControl.vue', () => {
@@ -145,4 +146,95 @@ describe('MSegmentedControl.vue', () => {
145
146
  expect(button.attributes('role')).toBe('radio');
146
147
  });
147
148
  });
149
+
150
+ it('forwards attrs to the radiogroup so callers can name the group', () => {
151
+ const wrapper = mount(MSegmentedControl, {
152
+ props: { segments },
153
+ attrs: { 'aria-label': 'View options' },
154
+ });
155
+ expect(wrapper.find('[role="radiogroup"]').attributes('aria-label')).toBe(
156
+ 'View options',
157
+ );
158
+ });
159
+
160
+ describe('Keyboard navigation (radiogroup pattern)', () => {
161
+ it('selected button has tabindex="0", others have tabindex="-1"', () => {
162
+ const wrapper = mount(MSegmentedControl, {
163
+ props: { segments, modelValue: '2' },
164
+ });
165
+ const buttons = wrapper.findAll('button');
166
+ expect(buttons[0].attributes('tabindex')).toBe('-1');
167
+ expect(buttons[1].attributes('tabindex')).toBe('0');
168
+ expect(buttons[2].attributes('tabindex')).toBe('-1');
169
+ });
170
+
171
+ it('keeps the first segment tabbable when modelValue does not match any segment', () => {
172
+ const wrapper = mount(MSegmentedControl, {
173
+ props: { segments, modelValue: 'unknown' },
174
+ });
175
+ const buttons = wrapper.findAll('button');
176
+ expect(buttons[0].attributes('tabindex')).toBe('0');
177
+ expect(buttons[1].attributes('tabindex')).toBe('-1');
178
+ expect(buttons[2].attributes('tabindex')).toBe('-1');
179
+ expect(buttons[0].attributes('aria-checked')).toBe('false');
180
+ });
181
+
182
+ it('ArrowRight selects and focuses the next segment', async () => {
183
+ const wrapper = mount(MSegmentedControl, {
184
+ attachTo: document.body,
185
+ props: { segments, modelValue: '1' },
186
+ });
187
+ const buttons = wrapper.findAll('button');
188
+ await buttons[0].element.focus();
189
+ await buttons[0].trigger('keydown', { key: 'ArrowRight' });
190
+ await nextTick();
191
+ expect(wrapper.emitted('update:modelValue')![0]).toEqual(['2']);
192
+ expect(document.activeElement).toBe(buttons[1].element);
193
+ wrapper.unmount();
194
+ });
195
+
196
+ it('ArrowLeft selects and focuses the previous segment', async () => {
197
+ const wrapper = mount(MSegmentedControl, {
198
+ attachTo: document.body,
199
+ props: { segments, modelValue: '2' },
200
+ });
201
+ const buttons = wrapper.findAll('button');
202
+ await buttons[1].trigger('keydown', { key: 'ArrowLeft' });
203
+ expect(wrapper.emitted('update:modelValue')![0]).toEqual(['1']);
204
+ wrapper.unmount();
205
+ });
206
+
207
+ it('ArrowRight wraps around from last to first segment', async () => {
208
+ const wrapper = mount(MSegmentedControl, {
209
+ attachTo: document.body,
210
+ props: { segments, modelValue: '3' },
211
+ });
212
+ const buttons = wrapper.findAll('button');
213
+ await buttons[2].trigger('keydown', { key: 'ArrowRight' });
214
+ expect(wrapper.emitted('update:modelValue')![0]).toEqual(['1']);
215
+ wrapper.unmount();
216
+ });
217
+
218
+ it('Home selects the first segment', async () => {
219
+ const wrapper = mount(MSegmentedControl, {
220
+ attachTo: document.body,
221
+ props: { segments, modelValue: '3' },
222
+ });
223
+ const buttons = wrapper.findAll('button');
224
+ await buttons[2].trigger('keydown', { key: 'Home' });
225
+ expect(wrapper.emitted('update:modelValue')![0]).toEqual(['1']);
226
+ wrapper.unmount();
227
+ });
228
+
229
+ it('End selects the last segment', async () => {
230
+ const wrapper = mount(MSegmentedControl, {
231
+ attachTo: document.body,
232
+ props: { segments, modelValue: '1' },
233
+ });
234
+ const buttons = wrapper.findAll('button');
235
+ await buttons[0].trigger('keydown', { key: 'End' });
236
+ expect(wrapper.emitted('update:modelValue')![0]).toEqual(['3']);
237
+ wrapper.unmount();
238
+ });
239
+ });
148
240
  });
@@ -1,8 +1,14 @@
1
1
  <template>
2
- <div class="mc-segmented-control" :class="classObject" role="radiogroup">
2
+ <div
3
+ class="mc-segmented-control"
4
+ :class="classObject"
5
+ role="radiogroup"
6
+ v-bind="$attrs"
7
+ >
3
8
  <button
4
9
  v-for="(segment, index) in segments"
5
10
  :key="`segment-${index}`"
11
+ :ref="(el) => setButtonRef(el, index)"
6
12
  type="button"
7
13
  class="mc-segmented-control__segment"
8
14
  :class="{
@@ -13,7 +19,9 @@
13
19
  }"
14
20
  :aria-checked="isSegmentSelected(index, segment.id)"
15
21
  role="radio"
22
+ :tabindex="index === selectedIndex ? 0 : -1"
16
23
  @click="onClickSegment(index, segment.id)"
24
+ @keydown="onKeydown($event, index)"
17
25
  >
18
26
  {{ segment.label }}
19
27
  </button>
@@ -21,7 +29,13 @@
21
29
  </template>
22
30
 
23
31
  <script setup lang="ts">
24
- import { computed, ref, watch } from 'vue';
32
+ import {
33
+ computed,
34
+ nextTick,
35
+ ref,
36
+ watch,
37
+ type ComponentPublicInstance,
38
+ } from 'vue';
25
39
  /**
26
40
  * A Segmented Control allows users to switch between multiple options or views within a single container. It provides a compact and efficient way to toggle between sections without requiring a dropdown or separate navigation. Segmented Controls are commonly used in filters, tabbed navigation, and content selection to enhance user interaction and accessibility.
27
41
  */
@@ -71,6 +85,17 @@ const classObject = computed(() => {
71
85
 
72
86
  const modelValue = ref<string | number | undefined>(props.modelValue);
73
87
 
88
+ const selectedIndex = computed(() => {
89
+ const index = props.segments.findIndex((segment, segmentIndex) => {
90
+ const value =
91
+ typeof props.modelValue === 'number' ? segmentIndex : segment.id;
92
+
93
+ return modelValue.value === value;
94
+ });
95
+
96
+ return index >= 0 ? index : 0;
97
+ });
98
+
74
99
  watch(
75
100
  () => props.modelValue,
76
101
  (newVal) => {
@@ -93,6 +118,40 @@ const isSegmentSelected = (index: number, id?: string) => {
93
118
  return modelValue.value === value;
94
119
  };
95
120
 
121
+ const buttonRefs = ref<(HTMLButtonElement | null)[]>([]);
122
+
123
+ function setButtonRef(
124
+ el: Element | ComponentPublicInstance | null,
125
+ index: number,
126
+ ) {
127
+ buttonRefs.value[index] = el as HTMLButtonElement | null;
128
+ }
129
+
130
+ function onKeydown(event: KeyboardEvent, index: number) {
131
+ let nextIndex: number | null = null;
132
+
133
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
134
+ event.preventDefault();
135
+ nextIndex = (index + 1) % props.segments.length;
136
+ } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
137
+ event.preventDefault();
138
+ nextIndex = (index - 1 + props.segments.length) % props.segments.length;
139
+ } else if (event.key === 'Home') {
140
+ event.preventDefault();
141
+ nextIndex = 0;
142
+ } else if (event.key === 'End') {
143
+ event.preventDefault();
144
+ nextIndex = props.segments.length - 1;
145
+ }
146
+
147
+ if (nextIndex !== null) {
148
+ onClickSegment(nextIndex, props.segments[nextIndex].id);
149
+ nextTick(() => buttonRefs.value[nextIndex!]?.focus());
150
+ }
151
+ }
152
+
153
+ defineOptions({ inheritAttrs: false });
154
+
96
155
  const emit = defineEmits<{
97
156
  /**
98
157
  * Emits when the selected segment changes, updating the modelValue prop.
@@ -4,20 +4,6 @@ import { nextTick } from 'vue';
4
4
  import MStarRating from './MStarRating.vue';
5
5
  import { StarFilled24, StarHalf24 } from '@mozaic-ds/icons-vue';
6
6
 
7
- function mockRect(el: Element, { left = 0, width = 100 } = {}) {
8
- Object.defineProperty(el, 'getBoundingClientRect', {
9
- value: () => ({
10
- left,
11
- width,
12
- top: 0,
13
- bottom: 0,
14
- right: left + width,
15
- height: 0,
16
- }),
17
- configurable: true,
18
- });
19
- }
20
-
21
7
  describe('MStarRating', () => {
22
8
  it('renders 5 stars by default', () => {
23
9
  const wrapper = shallowMount(MStarRating, { props: { modelValue: 0 } });
@@ -39,8 +25,7 @@ describe('MStarRating', () => {
39
25
  });
40
26
  const stars = wrapper.findAll('.mc-star-rating__icon');
41
27
  const first = stars[0];
42
- mockRect(first.element, { left: 0, width: 100 });
43
- await first.trigger('mousemove', { clientX: 10 });
28
+ await first.trigger('pointermove', { pointerType: 'mouse' });
44
29
  expect(wrapper.findComponent(StarHalf24).exists()).toBe(false);
45
30
  });
46
31
 
@@ -60,8 +45,7 @@ describe('MStarRating', () => {
60
45
  });
61
46
  const stars = wrapper.findAll('.mc-star-rating__icon');
62
47
  const first = stars[0];
63
- mockRect(first.element, { left: 0, width: 100 });
64
- await first.trigger('click', { clientX: 10 });
48
+ await first.trigger('click');
65
49
  const emitted = wrapper.emitted('update:modelValue') || [];
66
50
  expect(emitted.length).toBe(1);
67
51
  expect(emitted[0][0]).toBe(1);
@@ -109,9 +93,8 @@ describe('MStarRating', () => {
109
93
 
110
94
  const stars = wrapper.findAll('.mc-star-rating__icon');
111
95
  const first = stars[0];
112
- mockRect(first.element, { left: 0, width: 100 });
113
96
 
114
- await first.trigger('mousemove', { clientX: 10 });
97
+ await first.trigger('pointermove', { pointerType: 'mouse' });
115
98
  await nextTick();
116
99
 
117
100
  // aria-label should reflect hovered value (0.5) not modelValue (2)
@@ -124,6 +107,21 @@ describe('MStarRating', () => {
124
107
  expect(root.attributes('aria-label')).toContain('2');
125
108
  });
126
109
 
110
+ it('ignores non-mouse pointermove events for hover state', async () => {
111
+ const wrapper = shallowMount(MStarRating, {
112
+ props: { modelValue: 3, size: 'm', readonly: false },
113
+ });
114
+
115
+ const stars = wrapper.findAll('.mc-star-rating__icon');
116
+ const first = stars[0];
117
+ const root = wrapper.find('[role="slider"]');
118
+
119
+ await first.trigger('pointermove', { pointerType: 'touch' });
120
+ await nextTick();
121
+
122
+ expect(root.attributes('aria-label')).toContain('3');
123
+ });
124
+
127
125
  it('resets hover to null on blur (aria-label falls back to modelValue)', async () => {
128
126
  const wrapper = shallowMount(MStarRating, {
129
127
  props: { modelValue: 3, size: 'm', readonly: false },
@@ -131,12 +129,11 @@ describe('MStarRating', () => {
131
129
 
132
130
  const stars = wrapper.findAll('.mc-star-rating__icon');
133
131
  const secondStar = stars[1];
134
- mockRect(secondStar.element, { left: 0, width: 100 });
135
132
 
136
133
  const root = wrapper.find('[role="slider"]');
137
134
 
138
135
  await root.trigger('focus');
139
- await secondStar.trigger('mousemove', { clientX: 10 });
136
+ await secondStar.trigger('pointermove', { pointerType: 'mouse' });
140
137
  await nextTick();
141
138
  expect(root.attributes('aria-label')).toContain('2');
142
139
 
@@ -40,7 +40,7 @@
40
40
  v-on="
41
41
  !isReadonly
42
42
  ? {
43
- mousemove: () => onHover(index),
43
+ pointermove: (event: PointerEvent) => onHover(event, index),
44
44
  click: () => onClick(index),
45
45
  }
46
46
  : {}
@@ -152,7 +152,8 @@ function getStarComponent(index: number) {
152
152
  }
153
153
  }
154
154
 
155
- function onHover(index: number) {
155
+ function onHover(event: PointerEvent, index: number) {
156
+ if (event.pointerType !== 'mouse') return;
156
157
  hover.value = index + 1;
157
158
  }
158
159
 
@@ -16,11 +16,15 @@
16
16
  'mc-tabs__tab--disabled': tab.disabled,
17
17
  }"
18
18
  :aria-selected="isTabSelected(index, tab.id)"
19
+ :aria-disabled="tab.disabled || undefined"
20
+ :disabled="tab.disabled"
21
+ :tabindex="index === focusableTabIndex ? 0 : -1"
19
22
  type="button"
20
23
  @click="onClickTab(index, tab.id)"
24
+ @keydown="onKeydown($event, index)"
21
25
  >
22
26
  <span v-if="tab.icon" class="mc-tabs__icon">
23
- <component :is="tab.icon" />
27
+ <component :is="tab.icon" aria-hidden="true" />
24
28
  </span>
25
29
  <div class="mc-tabs__label">
26
30
  <span>{{ tab.label }}</span>
@@ -36,7 +40,7 @@
36
40
  </template>
37
41
 
38
42
  <script setup lang="ts">
39
- import { computed, ref, type Component } from 'vue';
43
+ import { computed, nextTick, useTemplateRef, type Component } from 'vue';
40
44
  import MDivider from '../divider/MDivider.vue';
41
45
  import MNumberBadge from '../numberbadge/MNumberBadge.vue';
42
46
  /**
@@ -101,7 +105,48 @@ const classObject = computed(() => {
101
105
  };
102
106
  });
103
107
 
104
- const modelValue = ref<string | number | undefined>(props.modelValue);
108
+ const modelValue = computed({
109
+ get() {
110
+ return props.modelValue;
111
+ },
112
+ set(value: string | number | undefined) {
113
+ emit('update:modelValue', value);
114
+ },
115
+ });
116
+
117
+ const enabledTabs = computed(() => {
118
+ return props.tabs.map((_, i) => i).filter((i) => !props.tabs[i].disabled);
119
+ });
120
+
121
+ const selectedTabIndex = computed(() => {
122
+ if (typeof modelValue.value === 'string') {
123
+ return props.tabs.findIndex((tab) => tab.id === modelValue.value);
124
+ }
125
+
126
+ if (
127
+ typeof modelValue.value === 'number' &&
128
+ modelValue.value >= 0 &&
129
+ modelValue.value < props.tabs.length
130
+ ) {
131
+ return modelValue.value;
132
+ }
133
+
134
+ return -1;
135
+ });
136
+
137
+ const focusableTabIndex = computed(() => {
138
+ if (enabledTabs.value.length === 0) {
139
+ return -1;
140
+ }
141
+
142
+ if (enabledTabs.value.includes(selectedTabIndex.value)) {
143
+ return selectedTabIndex.value;
144
+ }
145
+
146
+ return enabledTabs.value[0];
147
+ });
148
+
149
+ const tabRefs = useTemplateRef<HTMLButtonElement[]>('tab');
105
150
 
106
151
  const onClickTab = (index: number, id?: string) => {
107
152
  const tab =
@@ -114,7 +159,6 @@ const onClickTab = (index: number, id?: string) => {
114
159
  if (tab?.disabled) return;
115
160
  if (value !== modelValue.value) {
116
161
  modelValue.value = value;
117
- emit('update:modelValue', value);
118
162
  }
119
163
  };
120
164
 
@@ -124,6 +168,48 @@ const isTabSelected = (index: number, id?: string) => {
124
168
  return modelValue.value === value;
125
169
  };
126
170
 
171
+ function onKeydown(event: KeyboardEvent, index: number) {
172
+ if (
173
+ event.key !== 'ArrowRight' &&
174
+ event.key !== 'ArrowLeft' &&
175
+ event.key !== 'Home' &&
176
+ event.key !== 'End'
177
+ ) {
178
+ return;
179
+ }
180
+
181
+ if (enabledTabs.value.length === 0) {
182
+ return;
183
+ }
184
+
185
+ const pos = enabledTabs.value.indexOf(index);
186
+
187
+ let nextPos: number | null = null;
188
+ if (event.key === 'ArrowRight') {
189
+ event.preventDefault();
190
+ if (pos < 0) return;
191
+ nextPos = (pos + 1) % enabledTabs.value.length;
192
+ } else if (event.key === 'ArrowLeft') {
193
+ event.preventDefault();
194
+ if (pos < 0) return;
195
+ nextPos = (pos - 1 + enabledTabs.value.length) % enabledTabs.value.length;
196
+ } else if (event.key === 'Home') {
197
+ event.preventDefault();
198
+ nextPos = 0;
199
+ } else if (event.key === 'End') {
200
+ event.preventDefault();
201
+ nextPos = enabledTabs.value.length - 1;
202
+ }
203
+
204
+ if (nextPos !== null) {
205
+ const nextIndex = enabledTabs.value[nextPos];
206
+ onClickTab(nextIndex, props.tabs[nextIndex].id);
207
+ nextTick(() => {
208
+ tabRefs.value?.[nextIndex]?.focus();
209
+ });
210
+ }
211
+ }
212
+
127
213
  const emit = defineEmits<{
128
214
  /**
129
215
  * Emits when the selected tab changes, updating the modelValue prop.