@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
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
it,
|
|
4
|
+
expect,
|
|
5
|
+
vi,
|
|
6
|
+
beforeAll,
|
|
7
|
+
afterAll,
|
|
8
|
+
beforeEach,
|
|
9
|
+
afterEach,
|
|
10
|
+
} from 'vitest';
|
|
2
11
|
import { mount, VueWrapper } from '@vue/test-utils';
|
|
3
12
|
import { nextTick } from 'vue';
|
|
4
13
|
import MOptionListbox from './MOptionListbox.vue';
|
|
@@ -68,6 +77,34 @@ const optionsWithSections: ListboxOption[] = [
|
|
|
68
77
|
{ label: 'Carrot', value: 'carrot' },
|
|
69
78
|
];
|
|
70
79
|
|
|
80
|
+
let originalScrollIntoViewDescriptor: PropertyDescriptor | undefined;
|
|
81
|
+
const scrollIntoViewMock = vi.fn();
|
|
82
|
+
|
|
83
|
+
beforeAll(() => {
|
|
84
|
+
originalScrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
|
|
85
|
+
HTMLElement.prototype,
|
|
86
|
+
'scrollIntoView',
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
|
90
|
+
configurable: true,
|
|
91
|
+
value: scrollIntoViewMock,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterAll(() => {
|
|
96
|
+
if (originalScrollIntoViewDescriptor) {
|
|
97
|
+
Object.defineProperty(
|
|
98
|
+
HTMLElement.prototype,
|
|
99
|
+
'scrollIntoView',
|
|
100
|
+
originalScrollIntoViewDescriptor,
|
|
101
|
+
);
|
|
102
|
+
} else {
|
|
103
|
+
delete (HTMLElement.prototype as { scrollIntoView?: unknown })
|
|
104
|
+
.scrollIntoView;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
71
108
|
function mountListbox(props: Record<string, unknown> = {}): VueWrapper {
|
|
72
109
|
return mount(MOptionListbox, {
|
|
73
110
|
props: {
|
|
@@ -389,6 +426,7 @@ describe('MOptionListbox – search / filtering', () => {
|
|
|
389
426
|
describe('MOptionListbox – keyboard navigation', () => {
|
|
390
427
|
beforeEach(() => {
|
|
391
428
|
vi.useFakeTimers();
|
|
429
|
+
scrollIntoViewMock.mockClear();
|
|
392
430
|
});
|
|
393
431
|
|
|
394
432
|
afterEach(() => {
|
|
@@ -455,6 +493,21 @@ describe('MOptionListbox – keyboard navigation', () => {
|
|
|
455
493
|
expect(wrapper.emitted('open')).toBeDefined();
|
|
456
494
|
});
|
|
457
495
|
|
|
496
|
+
it('ArrowDown when closed sets activeIndex to selected option', async () => {
|
|
497
|
+
const wrapper = mountListbox({
|
|
498
|
+
search: true,
|
|
499
|
+
open: false,
|
|
500
|
+
modelValue: 'banana',
|
|
501
|
+
});
|
|
502
|
+
const input = wrapper.find('input');
|
|
503
|
+
|
|
504
|
+
await input.trigger('keydown', { key: 'ArrowDown' });
|
|
505
|
+
await nextTick();
|
|
506
|
+
|
|
507
|
+
const items = wrapper.findAll('.mc-option-listbox__item');
|
|
508
|
+
expect(items[1].classes()).toContain('mc-option-listbox__item--active');
|
|
509
|
+
});
|
|
510
|
+
|
|
458
511
|
it('ArrowUp when closed emits open and sets activeIndex to last item', async () => {
|
|
459
512
|
const wrapper = mountListbox({ search: true, open: false });
|
|
460
513
|
const input = wrapper.find('input');
|
|
@@ -474,6 +527,30 @@ describe('MOptionListbox – keyboard navigation', () => {
|
|
|
474
527
|
const items = wrapper.findAll('.mc-option-listbox__item');
|
|
475
528
|
expect(items[2].classes()).toContain('mc-option-listbox__item--active');
|
|
476
529
|
});
|
|
530
|
+
|
|
531
|
+
it('scrolls the active option into view during ArrowDown navigation', async () => {
|
|
532
|
+
const wrapper = mountListbox({ search: true, open: true });
|
|
533
|
+
const input = wrapper.find('input');
|
|
534
|
+
|
|
535
|
+
await input.trigger('keydown', { key: 'ArrowDown' });
|
|
536
|
+
await nextTick();
|
|
537
|
+
|
|
538
|
+
expect(scrollIntoViewMock).toHaveBeenCalledTimes(1);
|
|
539
|
+
expect(scrollIntoViewMock).toHaveBeenCalledWith({
|
|
540
|
+
block: 'nearest',
|
|
541
|
+
inline: 'nearest',
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('does not scroll active option when listbox is closed', async () => {
|
|
546
|
+
const wrapper = mountListbox({ search: true, open: false });
|
|
547
|
+
const input = wrapper.find('input');
|
|
548
|
+
|
|
549
|
+
await input.trigger('keydown', { key: 'ArrowDown' });
|
|
550
|
+
await nextTick();
|
|
551
|
+
|
|
552
|
+
expect(scrollIntoViewMock).not.toHaveBeenCalled();
|
|
553
|
+
});
|
|
477
554
|
});
|
|
478
555
|
|
|
479
556
|
describe('MOptionListbox – exposed API', () => {
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
3
|
-
ref="listboxEl"
|
|
4
|
-
class="mc-option-listbox mc-listbox__content mc-combobox__listbox"
|
|
5
|
-
>
|
|
2
|
+
<div ref="listboxEl" class="mc-option-listbox">
|
|
6
3
|
<template v-if="search">
|
|
7
4
|
<div class="mc-option-listbox__search">
|
|
8
5
|
<MTextInput
|
|
@@ -42,6 +39,7 @@
|
|
|
42
39
|
</template>
|
|
43
40
|
|
|
44
41
|
<ul
|
|
42
|
+
ref="optionsList"
|
|
45
43
|
class="mc-option-listbox__list"
|
|
46
44
|
role="listbox"
|
|
47
45
|
:id="`listbox-${id}`"
|
|
@@ -128,7 +126,7 @@
|
|
|
128
126
|
</template>
|
|
129
127
|
|
|
130
128
|
<script setup lang="ts">
|
|
131
|
-
import {
|
|
129
|
+
import { ref, useTemplateRef, watch, type VNode } from 'vue';
|
|
132
130
|
import MButton from '../button/MButton.vue';
|
|
133
131
|
import MTextInput from '../textinput/MTextInput.vue';
|
|
134
132
|
import {
|
|
@@ -137,34 +135,16 @@ import {
|
|
|
137
135
|
Less20,
|
|
138
136
|
Check20,
|
|
139
137
|
} from '@mozaic-ds/icons-vue';
|
|
138
|
+
import { debounce } from '../../utils/debounce';
|
|
139
|
+
import { useListboxSelection } from './use-listbox-selection.composable';
|
|
140
|
+
import { useListboxNavigation } from './use-listbox-navigation.composable';
|
|
141
|
+
import type { ListboxOption } from './MOptionListbox.types';
|
|
140
142
|
|
|
141
|
-
|
|
142
|
-
fn: T,
|
|
143
|
-
wait: number,
|
|
144
|
-
): (...args: Parameters<T>) => void {
|
|
145
|
-
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
146
|
-
|
|
147
|
-
return function (this: unknown, ...args: Parameters<T>) {
|
|
148
|
-
if (timeoutId) {
|
|
149
|
-
clearTimeout(timeoutId);
|
|
150
|
-
}
|
|
151
|
-
timeoutId = setTimeout(() => {
|
|
152
|
-
fn.apply(this, args);
|
|
153
|
-
}, wait);
|
|
154
|
-
};
|
|
155
|
-
}
|
|
143
|
+
export type { ListboxOption };
|
|
156
144
|
|
|
157
145
|
/**
|
|
158
146
|
* An Option Listbox is a customizable, accessible listbox component designed to power dropdowns and comboboxes with advanced selection capabilities. It supports single or multiple selection, optional search, grouped options with section headers, and full keyboard navigation.
|
|
159
147
|
*/
|
|
160
|
-
export type ListboxOption = {
|
|
161
|
-
label: string;
|
|
162
|
-
content?: string;
|
|
163
|
-
value?: string | number;
|
|
164
|
-
disabled?: boolean;
|
|
165
|
-
type?: 'option' | 'section';
|
|
166
|
-
};
|
|
167
|
-
|
|
168
148
|
const props = withDefaults(
|
|
169
149
|
defineProps<{
|
|
170
150
|
/**
|
|
@@ -249,28 +229,35 @@ defineSlots<{
|
|
|
249
229
|
}>();
|
|
250
230
|
|
|
251
231
|
const listboxEl = useTemplateRef('listboxEl');
|
|
232
|
+
const optionsList = useTemplateRef('optionsList');
|
|
252
233
|
const textInput = useTemplateRef('textInput');
|
|
253
234
|
|
|
254
|
-
const activeIndex = ref<number>(-1);
|
|
255
|
-
|
|
256
235
|
const searchText = ref('');
|
|
257
|
-
|
|
258
236
|
const filteredResults = ref<ListboxOption[]>(props.options);
|
|
259
237
|
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
238
|
+
const {
|
|
239
|
+
isSelectable,
|
|
240
|
+
isOptionSelected,
|
|
241
|
+
isSectionSelected,
|
|
242
|
+
isIndeterminate,
|
|
243
|
+
toggleValue,
|
|
244
|
+
toggleSection,
|
|
245
|
+
selectAll,
|
|
246
|
+
clearSelection,
|
|
247
|
+
} = useListboxSelection(props, emit);
|
|
268
248
|
|
|
269
|
-
const activeDescendantId =
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
249
|
+
const { activeIndex, activeDescendantId, handleKeydown } = useListboxNavigation(
|
|
250
|
+
props,
|
|
251
|
+
(on) => (on === 'open' ? emit('open') : emit('close')),
|
|
252
|
+
{
|
|
253
|
+
filteredResults,
|
|
254
|
+
optionsList,
|
|
255
|
+
isSelectable,
|
|
256
|
+
isOptionSelected,
|
|
257
|
+
toggleValue,
|
|
258
|
+
toggleSection,
|
|
259
|
+
},
|
|
260
|
+
);
|
|
274
261
|
|
|
275
262
|
const updateFilteredResults = debounce(() => {
|
|
276
263
|
const search = searchText.value.toLowerCase().trim();
|
|
@@ -287,179 +274,6 @@ const updateFilteredResults = debounce(() => {
|
|
|
287
274
|
activeIndex.value = filteredResults.value.length ? 0 : -1;
|
|
288
275
|
}, 200);
|
|
289
276
|
|
|
290
|
-
const sectionMap = computed(() => {
|
|
291
|
-
const map = new Map<string, ListboxOption[]>();
|
|
292
|
-
let currentSection: ListboxOption | null = null;
|
|
293
|
-
|
|
294
|
-
props.options.forEach((option) => {
|
|
295
|
-
if (option.type === 'section') {
|
|
296
|
-
currentSection = option;
|
|
297
|
-
map.set(currentSection?.value?.toString() || currentSection.label, []);
|
|
298
|
-
} else if (currentSection) {
|
|
299
|
-
map
|
|
300
|
-
.get(currentSection?.value?.toString() || currentSection.label)!
|
|
301
|
-
.push(option);
|
|
302
|
-
}
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
return map;
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
function toggleSection(item: ListboxOption) {
|
|
309
|
-
if (!props.checkableSections || !props.multiple) return;
|
|
310
|
-
|
|
311
|
-
const sectionItems =
|
|
312
|
-
sectionMap.value.get(item.value?.toString() || item.label) || [];
|
|
313
|
-
const selectedItems = selection.value as (string | number)[];
|
|
314
|
-
|
|
315
|
-
if (isSectionSelected(item)) {
|
|
316
|
-
selection.value = selectedItems.filter(
|
|
317
|
-
(opt) => !sectionItems.find((item) => item.value === opt),
|
|
318
|
-
);
|
|
319
|
-
} else {
|
|
320
|
-
selection.value = [
|
|
321
|
-
...selectedItems,
|
|
322
|
-
...sectionItems
|
|
323
|
-
.filter((opt) => !selectedItems.includes(opt.value!))
|
|
324
|
-
.map((item) => item.value!),
|
|
325
|
-
];
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function toggleValue(item?: ListboxOption) {
|
|
330
|
-
if (!item || !isSelectable(item)) return;
|
|
331
|
-
|
|
332
|
-
if (Array.isArray(selection.value)) {
|
|
333
|
-
if (isOptionSelected(item)) {
|
|
334
|
-
selection.value = (selection.value as (string | number)[]).filter(
|
|
335
|
-
(el) => el !== item.value,
|
|
336
|
-
);
|
|
337
|
-
} else {
|
|
338
|
-
selection.value = [...selection.value, item.value!];
|
|
339
|
-
}
|
|
340
|
-
} else {
|
|
341
|
-
selection.value = isOptionSelected(item) ? null : item.value!;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
function selectAll() {
|
|
346
|
-
selection.value = [
|
|
347
|
-
...props.options
|
|
348
|
-
.filter(
|
|
349
|
-
(option) =>
|
|
350
|
-
!!option.value && !option.disabled && option.type !== 'section',
|
|
351
|
-
)
|
|
352
|
-
.map((item) => item.value!),
|
|
353
|
-
];
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
function clearSelection() {
|
|
357
|
-
if (Array.isArray(selection.value)) {
|
|
358
|
-
selection.value = [];
|
|
359
|
-
} else {
|
|
360
|
-
selection.value = null;
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
function isSelectable(item: ListboxOption) {
|
|
365
|
-
return (
|
|
366
|
-
(item.type !== 'section' && !item.disabled) ||
|
|
367
|
-
(item.type === 'section' && props.checkableSections && props.multiple)
|
|
368
|
-
);
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function isSectionSelected(item: ListboxOption) {
|
|
372
|
-
if (!props.checkableSections || !props.multiple) return false;
|
|
373
|
-
|
|
374
|
-
const sectionItems =
|
|
375
|
-
sectionMap.value.get(item.value?.toString() || item.label) || [];
|
|
376
|
-
const selectedItems = selection.value as (string | number)[];
|
|
377
|
-
|
|
378
|
-
return sectionItems.every((opt) => selectedItems.includes(opt.value!));
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function isOptionSelected(item: ListboxOption) {
|
|
382
|
-
if (!item.value) return false;
|
|
383
|
-
|
|
384
|
-
if (Array.isArray(selection.value)) {
|
|
385
|
-
return (selection.value as (string | number)[])?.includes(item.value!);
|
|
386
|
-
} else {
|
|
387
|
-
return item.value === selection.value;
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
function isIndeterminate(item: ListboxOption) {
|
|
392
|
-
const section = sectionMap.value.get(item.value?.toString() || item.label);
|
|
393
|
-
return (
|
|
394
|
-
section?.some((option) => isOptionSelected(option)) &&
|
|
395
|
-
!section?.every((option) => isOptionSelected(option))
|
|
396
|
-
);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
function moveActive(delta: number) {
|
|
400
|
-
if (!props.open || filteredResults.value.length === 0) return;
|
|
401
|
-
|
|
402
|
-
let nextIndex = activeIndex.value + delta;
|
|
403
|
-
|
|
404
|
-
if (nextIndex < 0) nextIndex = filteredResults.value.length - 1;
|
|
405
|
-
if (nextIndex >= filteredResults.value.length) nextIndex = 0;
|
|
406
|
-
|
|
407
|
-
while (!isSelectable(filteredResults.value[nextIndex])) {
|
|
408
|
-
nextIndex += delta > 0 ? 1 : -1;
|
|
409
|
-
if (nextIndex < 0) nextIndex = filteredResults.value.length - 1;
|
|
410
|
-
if (nextIndex >= filteredResults.value.length) nextIndex = 0;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
activeIndex.value = nextIndex;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
function selectActive() {
|
|
417
|
-
const item = filteredResults.value[activeIndex.value];
|
|
418
|
-
if (!item || !isSelectable(item)) return;
|
|
419
|
-
|
|
420
|
-
if (item.type === 'section') {
|
|
421
|
-
toggleSection(item);
|
|
422
|
-
} else {
|
|
423
|
-
toggleValue(item);
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
function handleKeydown(event: KeyboardEvent) {
|
|
428
|
-
switch (event.key) {
|
|
429
|
-
case 'ArrowDown':
|
|
430
|
-
event.preventDefault();
|
|
431
|
-
if (!props.open) {
|
|
432
|
-
emit('open');
|
|
433
|
-
activeIndex.value = 0;
|
|
434
|
-
} else {
|
|
435
|
-
moveActive(1);
|
|
436
|
-
}
|
|
437
|
-
break;
|
|
438
|
-
case 'ArrowUp':
|
|
439
|
-
event.preventDefault();
|
|
440
|
-
if (!props.open) {
|
|
441
|
-
emit('open');
|
|
442
|
-
activeIndex.value = filteredResults.value.length - 1;
|
|
443
|
-
} else {
|
|
444
|
-
moveActive(-1);
|
|
445
|
-
}
|
|
446
|
-
break;
|
|
447
|
-
case 'Enter':
|
|
448
|
-
event.preventDefault();
|
|
449
|
-
if (!props.open) {
|
|
450
|
-
emit('open');
|
|
451
|
-
activeIndex.value = 0;
|
|
452
|
-
} else {
|
|
453
|
-
selectActive();
|
|
454
|
-
}
|
|
455
|
-
break;
|
|
456
|
-
case 'Escape':
|
|
457
|
-
event.preventDefault();
|
|
458
|
-
emit('close');
|
|
459
|
-
break;
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
277
|
watch(
|
|
464
278
|
() => props.open,
|
|
465
279
|
(v) => {
|
|
@@ -53,11 +53,13 @@ style MOptionListbox fill:#008240,stroke:#333,stroke-width:4px
|
|
|
53
53
|
### Used By
|
|
54
54
|
|
|
55
55
|
- [MCombobox](../combobox)
|
|
56
|
+
- [MPageHeaderContext](../pageheadercontext)
|
|
56
57
|
|
|
57
58
|
### Graph
|
|
58
59
|
|
|
59
60
|
```mermaid
|
|
60
61
|
graph TD;
|
|
61
62
|
MCombobox --> MOptionListbox
|
|
63
|
+
MPageHeaderContext --> MOptionListbox
|
|
62
64
|
style MOptionListbox fill:#008240,stroke:#333,stroke-width:4px
|
|
63
65
|
```
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { computed, nextTick, ref, watch, type Ref, type ShallowRef } from 'vue';
|
|
2
|
+
import type { ListboxOption } from './MOptionListbox.types';
|
|
3
|
+
|
|
4
|
+
type Props = {
|
|
5
|
+
open?: boolean;
|
|
6
|
+
id: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type Deps = {
|
|
10
|
+
filteredResults: Ref<ListboxOption[]>;
|
|
11
|
+
optionsList: Readonly<ShallowRef<HTMLElement | null>>;
|
|
12
|
+
isSelectable: (item: ListboxOption) => boolean;
|
|
13
|
+
isOptionSelected: (item: ListboxOption) => boolean;
|
|
14
|
+
toggleValue: (item: ListboxOption) => void;
|
|
15
|
+
toggleSection: (item: ListboxOption) => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function useListboxNavigation(
|
|
19
|
+
props: Props,
|
|
20
|
+
emit: (on: 'open' | 'close') => void,
|
|
21
|
+
deps: Deps,
|
|
22
|
+
) {
|
|
23
|
+
const activeIndex = ref<number>(-1);
|
|
24
|
+
|
|
25
|
+
const activeDescendantId = computed(() =>
|
|
26
|
+
activeIndex.value >= 0
|
|
27
|
+
? `option-${props.id}-${activeIndex.value}`
|
|
28
|
+
: undefined,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
function moveActive(delta: number) {
|
|
32
|
+
if (!props.open || deps.filteredResults.value.length === 0) return;
|
|
33
|
+
|
|
34
|
+
const total = deps.filteredResults.value.length;
|
|
35
|
+
let nextIndex = activeIndex.value;
|
|
36
|
+
for (let i = 0; i < total; i++) {
|
|
37
|
+
nextIndex += delta;
|
|
38
|
+
if (nextIndex < 0) nextIndex = total - 1;
|
|
39
|
+
if (nextIndex >= total) nextIndex = 0;
|
|
40
|
+
if (deps.isSelectable(deps.filteredResults.value[nextIndex])) {
|
|
41
|
+
activeIndex.value = nextIndex;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getFirstSelectableIndex() {
|
|
48
|
+
return deps.filteredResults.value.findIndex((item) =>
|
|
49
|
+
deps.isSelectable(item),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getLastSelectableIndex() {
|
|
54
|
+
for (let i = deps.filteredResults.value.length - 1; i >= 0; i--) {
|
|
55
|
+
if (deps.isSelectable(deps.filteredResults.value[i])) return i;
|
|
56
|
+
}
|
|
57
|
+
return -1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getSelectedOptionIndex() {
|
|
61
|
+
return deps.filteredResults.value.findIndex(
|
|
62
|
+
(item) =>
|
|
63
|
+
item.type !== 'section' &&
|
|
64
|
+
deps.isSelectable(item) &&
|
|
65
|
+
deps.isOptionSelected(item),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getInitialActiveIndex(direction: 'forward' | 'backward') {
|
|
70
|
+
const selectedIndex = getSelectedOptionIndex();
|
|
71
|
+
|
|
72
|
+
if (selectedIndex >= 0) return selectedIndex;
|
|
73
|
+
|
|
74
|
+
return direction === 'backward'
|
|
75
|
+
? getLastSelectableIndex()
|
|
76
|
+
: getFirstSelectableIndex();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function selectActive() {
|
|
80
|
+
const item = deps.filteredResults.value[activeIndex.value];
|
|
81
|
+
if (!item || !deps.isSelectable(item)) return;
|
|
82
|
+
|
|
83
|
+
if (item.type === 'section') {
|
|
84
|
+
deps.toggleSection(item);
|
|
85
|
+
} else {
|
|
86
|
+
deps.toggleValue(item);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function scrollActiveOptionIntoView() {
|
|
91
|
+
if (!props.open || activeIndex.value < 0) return;
|
|
92
|
+
|
|
93
|
+
const activeOption = deps.optionsList.value?.querySelector<HTMLElement>(
|
|
94
|
+
`#option-${props.id}-${activeIndex.value}`,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
activeOption?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function handleKeydown(event: KeyboardEvent) {
|
|
101
|
+
switch (event.key) {
|
|
102
|
+
case 'ArrowDown':
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
if (!props.open) {
|
|
105
|
+
emit('open');
|
|
106
|
+
activeIndex.value = getInitialActiveIndex('forward');
|
|
107
|
+
} else {
|
|
108
|
+
moveActive(1);
|
|
109
|
+
}
|
|
110
|
+
break;
|
|
111
|
+
case 'ArrowUp':
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
if (!props.open) {
|
|
114
|
+
emit('open');
|
|
115
|
+
activeIndex.value = getInitialActiveIndex('backward');
|
|
116
|
+
} else {
|
|
117
|
+
moveActive(-1);
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
case 'Enter':
|
|
121
|
+
event.preventDefault();
|
|
122
|
+
if (!props.open) {
|
|
123
|
+
emit('open');
|
|
124
|
+
activeIndex.value = getInitialActiveIndex('forward');
|
|
125
|
+
} else {
|
|
126
|
+
selectActive();
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
case 'Escape':
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
emit('close');
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
watch([activeIndex, () => props.open], async ([index, isOpen]) => {
|
|
137
|
+
if (!isOpen || index < 0) return;
|
|
138
|
+
await nextTick();
|
|
139
|
+
scrollActiveOptionIntoView();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
activeIndex,
|
|
144
|
+
activeDescendantId,
|
|
145
|
+
handleKeydown,
|
|
146
|
+
};
|
|
147
|
+
}
|