@teammodlr/ui-kit 2.1.62 → 2.1.64

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.
@@ -1,755 +1,764 @@
1
- <template>
2
- <div ref="selectRef" tabindex="-1">
3
- <label v-if="label" :for="id" :class="style.label" :id="`${id}-label`">
4
- {{ label }}
5
- </label>
6
- <div v-if="description" :class="style.description">
7
- {{ description }}
8
- </div>
9
-
10
- <div :class="computedWrapperClasses" v-on="inputListeners" :id="id" ref="container"
11
- :aria-disabled="disabled ? 'true' : 'false'" :aria-labelledby="`${id}-label`" tabindex="0"
12
- @mousedown.prevent="onSelectClick" @focusin.stop="focusSelect">
13
- <div :class="selectComputedClasses">
14
- <template v-if="
15
- multiple &&
16
- Array.isArray(selected) &&
17
- selected.length > 0
18
- ">
19
- <MTag v-for="value in selectedMax" :key="value" :closeable="true"
20
- @close="event => removeSelected(event, value)" :size="size">
21
- {{ getSelectedLabel(value) }}
22
- </MTag>
23
- <template v-if="selectedCountNotShown > 0">
24
- <MTag :size="size">
25
- +{{ selectedCountNotShown }} more
26
- </MTag>
27
- </template>
28
- </template>
29
- <template v-else-if="!multiple && selected">
30
- {{ getSelectedLabel(selected) }}
31
- </template>
32
- <template v-else>
33
- <div :class="style.placeholder">{{ placeholder }}</div>
34
- </template>
35
- </div>
36
-
37
- <Teleport :to="'body'">
38
- <Transition :enter-active-class="scale.enterActive" :leave-active-class="scale.leaveActive"
39
- :enter-from-class="scale.enterFrom" :leave-to-class="scale.leaveTo">
40
- <div :class="style.menu" v-if="isOpen" ref="popper" :style="computedConsistentWidth"
41
- :aria-hidden="isOpen ? 'false' : 'true'">
42
- <div v-if="searchable || (multiple && selectAll)" :class="style.search">
43
- <div v-if="multiple && selectAll" :class="style.checkAll">
44
- <button role="button" type="button" :class="{
45
- [style.checkAllButton]: true,
46
- [style.checkAllButtonActive]:
47
- computedOptions.length > 0 &&
48
- computedOptions.length ===
49
- selected.length
50
- }" @click.prevent="toggleAllItems" aria-description="Select all items">
51
- <svg v-if="
52
- computedOptions.length > 0 &&
53
- computedOptions.length ===
54
- selected.length
55
- " xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
56
- aria-hidden="true">
57
- <path fill-rule="evenodd"
58
- d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
59
- clip-rule="evenodd"></path>
60
- </svg>
61
- </button>
62
- </div>
63
- <input v-if="searchable" type="text" ref="searchRef" v-model="search" placeholder="Search"
64
- :class="style.searchInput" />
65
- </div>
66
- <div :class="style.options">
67
- <div v-for="(option, index) in computedOptions" :key="index" ref="optionsRef" :class="{
68
- [style.option]: true,
69
- [style.optionSelected]: option.active,
70
- [style.optionFocused]:
71
- selectedIndex === index
72
- }" @click.prevent="option.onClick" @hover="selectedIndex = index">
73
- <span :class="style.optionIcon">
74
- <svg v-if="option.active" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
75
- fill="currentColor" aria-hidden="true">
76
- <path fill-rule="evenodd"
77
- d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
78
- clip-rule="evenodd"></path>
79
- </svg>
80
- </span>
81
- <span :class="style.optionLabel">{{
82
- option.label
83
- }}</span>
84
- </div>
85
- <template v-if="computedOptions.length === 0">
86
- <div :class="style.noResults">No results</div>
87
- </template>
88
- </div>
89
- </div>
90
- </Transition>
91
- </Teleport>
92
- </div>
93
- <div v-if="inputError" :class="style.errorMessage">
94
- {{ inputError }}
95
- </div>
96
- </div>
97
- </template>
98
-
99
- <script lang="ts">
100
- import { useInput } from '../../composables/useInput';
101
- import { usePopper, type Placement } from '../../composables/usePopper';
102
- import type { MaybeArray, Size } from '../../types/props';
103
- import { onClickOutside, useEventListener } from '@vueuse/core';
104
- import {
105
- computed,
106
- defineComponent,
107
- nextTick,
108
- ref,
109
- watch,
110
- watchEffect,
111
- type PropType
112
- } from 'vue';
113
- import MTag from '../tag/Tag.vue';
114
- import * as style from './style';
115
- import { scale } from '../../styles/transitions.css';
116
- import { useInteractive } from '../../composables/useInteractive';
117
- import type { OnUpdateValue, OnUpdateValueImpl } from './types';
118
- import { call } from "../../utils/call";
119
-
120
- export default defineComponent({
121
- name: 'MSelect',
122
- emits: {
123
- ...useInput.emits()
124
- },
125
- props: {
126
- ...useInput.props(),
127
- ...useInteractive.props(),
128
- label: {
129
- type: String
130
- },
131
- placeholder: {
132
- type: String,
133
- default: 'Select an option'
134
- },
135
- options: {
136
- type: Array as PropType<Array<any>>,
137
- required: true
138
- },
139
- searchable: {
140
- type: Boolean,
141
- default: false
142
- },
143
- multiple: {
144
- type: Boolean,
145
- default: false
146
- },
147
- size: {
148
- type: String as PropType<Size>,
149
- default: 'md'
150
- },
151
- description: {
152
- type: String,
153
- default: ''
154
- },
155
- optionValue: {
156
- type: String,
157
- default: 'value'
158
- },
159
- optionLabel: {
160
- type: String,
161
- default: 'label'
162
- },
163
- transitionName: {
164
- type: String,
165
- default: 'fade'
166
- },
167
- consistentWidth: {
168
- type: Boolean,
169
- default: true
170
- },
171
- disabled: {
172
- type: Boolean,
173
- default: false
174
- },
175
- loading: {
176
- type: Boolean,
177
- default: false
178
- },
179
- error: {
180
- type: String
181
- },
182
- allowBlank: {
183
- type: Boolean,
184
- default: false
185
- },
186
- selectAll: {
187
- type: Boolean,
188
- default: false
189
- },
190
- max: {
191
- type: Number,
192
- default: 5
193
- },
194
- 'onUpdate:value': [Function, Array] as PropType<MaybeArray<OnUpdateValue> | undefined>,
195
- onUpdateValue: [Function, Array] as PropType<MaybeArray<OnUpdateValue> | undefined>,
196
- },
197
- components: {
198
- MTag
199
- },
200
- setup(props, { emit, expose }) {
201
- const container = ref<HTMLElement | null>(null);
202
- const wrapper = ref<HTMLElement>();
203
- const optionsRef = ref<HTMLElement[]>();
204
- const searchRef = ref<HTMLElement>();
205
- const selectRef = ref<HTMLElement>();
206
- const selectedIndex = ref<number | undefined>(0);
207
- const open = ref<boolean>(false);
208
- const focused = ref<boolean>(false);
209
- const search = ref<string>('');
210
- const placement = ref<Placement>('bottom-start');
211
- const transfer = ref(false);
212
- const width = ref<number>(0);
213
- const keyboardNavigation = ref<boolean>(false);
214
-
215
- function doUpdateValue(
216
- value: string | number | Array<string | number> | null,
217
- option: object | null
218
- ): void {
219
- const {
220
- 'onUpdate:value': _onUpdateValue,
221
- onUpdateValue
222
- } = props;
223
-
224
- if (onUpdateValue) call(onUpdateValue as OnUpdateValueImpl, value, option)
225
- if (_onUpdateValue) {
226
- call(_onUpdateValue as OnUpdateValueImpl, value, option)
227
- }
228
- }
229
-
230
-
231
- const { focus, blur } = useInteractive(container);
232
- // TODO: Add debounce on keyboard movement (hover flicks it back up)
233
- const {
234
- id,
235
- insideForm,
236
- inputListeners,
237
- formError,
238
- formDisabled,
239
- formLoading
240
- } = useInput(props, { focus, emit, withListeners: false });
241
-
242
- const { popper, updatePopper } = usePopper({
243
- placement,
244
- transfer,
245
- reference: container,
246
- wrapper,
247
- offset: [0, 5]
248
- });
249
-
250
- const disabled = computed(() => formDisabled || props.disabled);
251
- const loading = computed(() => formLoading || props.loading);
252
-
253
- const selected = computed<any | any[]>({
254
- get() {
255
- if (props.multiple) {
256
- if (!props.modelValue) {
257
- return [];
258
- }
259
-
260
- if (Array.isArray(props.modelValue)) {
261
- return props.modelValue;
262
- }
263
-
264
- return [props.modelValue];
265
- }
266
-
267
- return props.modelValue || null;
268
- },
269
- set(value: string | number | []) {
270
- emit('update:modelValue', value);
271
- doUpdateValue(value, null);
272
- }
273
- });
274
-
275
- const selectedMax = computed(() => {
276
- if (props.max <= 0) {
277
- return selected.value;
278
- }
279
-
280
- if (
281
- props.multiple &&
282
- Array.isArray(selected.value) &&
283
- selected.value.length > props.max
284
- ) {
285
- return selected.value.slice(0, props.max);
286
- }
287
-
288
- return selected.value;
289
- });
290
-
291
- const selectedCountNotShown = computed(() => {
292
- if (
293
- props.multiple &&
294
- Array.isArray(selected.value) &&
295
- props.max > 0
296
- ) {
297
- return selected.value.length - props.max;
298
- }
299
-
300
- return 0;
301
- });
302
-
303
- const computedConsistentWidth = computed(() => {
304
- if (props.consistentWidth) {
305
- return {
306
- width: width.value + 'px'
307
- };
308
- }
309
-
310
- return {};
311
- });
312
-
313
- const computedOptions = computed(() => {
314
- if (!props.options || props.options.length == 0) {
315
- return [];
316
- }
317
-
318
- // Filter options by search
319
- if (props.searchable && search.value) {
320
- let options = props.options
321
- .filter(option => {
322
- return option[props.optionLabel]
323
- .toLowerCase()
324
- .includes(search.value.toLowerCase());
325
- })
326
- .map(option => {
327
- if (option[props.optionValue] === undefined) {
328
- throw new Error('Option value is undefined');
329
- }
330
-
331
- if (option[props.optionLabel] === undefined) {
332
- throw new Error('Option label is undefined');
333
- }
334
-
335
- let value = option[props.optionValue];
336
- let active: boolean;
337
- if (props.multiple && Array.isArray(selected.value)) {
338
- active = selected.value.includes(value);
339
- } else {
340
- active = selected.value === value;
341
- }
342
-
343
- return {
344
- value: value,
345
- label: option[props.optionLabel],
346
- active: active,
347
- onClick: () => handleOptionClick(value)
348
- };
349
- });
350
-
351
- if (props.allowBlank) {
352
- options.unshift({
353
- value: '',
354
- label: '',
355
- active: false,
356
- onClick: () => handleOptionClick('')
357
- });
358
- }
359
-
360
- return options;
361
- }
362
-
363
- let options = props.options.map(option => {
364
- if (option[props.optionValue] === undefined) {
365
- throw new Error('Option value is undefined');
366
- }
367
-
368
- if (option[props.optionLabel] === undefined) {
369
- throw new Error('Option label is undefined');
370
- }
371
-
372
- let value = option[props.optionValue];
373
- let active: boolean;
374
- if (props.multiple && Array.isArray(selected.value)) {
375
- active = selected.value.includes(value);
376
- } else {
377
- active = selected.value === value;
378
- }
379
-
380
- return {
381
- value: value,
382
- label: option[props.optionLabel],
383
- active: active,
384
- onClick: () => handleOptionClick(value)
385
- };
386
- });
387
-
388
- if (props.allowBlank) {
389
- options.unshift({
390
- value: '',
391
- label: '',
392
- active: false,
393
- onClick: () => handleOptionClick('')
394
- });
395
- }
396
-
397
- return options;
398
- });
399
-
400
- function toggleAllItems() {
401
- if (!props.multiple) {
402
- return;
403
- }
404
-
405
- if (computedOptions.value.length === 0) {
406
- return;
407
- }
408
-
409
- if (Array.isArray(selected.value)) {
410
- if (selected.value.length === computedOptions.value.length) {
411
- selected.value = [];
412
- } else {
413
- selected.value = computedOptions.value.map(
414
- option => option.value
415
- );
416
- }
417
- } else {
418
- selected.value = [];
419
- }
420
- }
421
-
422
- function openSelect() {
423
- if (disabled.value || loading.value) {
424
- return;
425
- }
426
-
427
- if (open.value) {
428
- open.value = false;
429
- return;
430
- }
431
- open.value = true;
432
- }
433
-
434
- function getSelectedIndex(
435
- start: number | undefined,
436
- direction = 'down'
437
- ) {
438
- if (typeof start === 'undefined') {
439
- start = direction === 'down' ? -1 : 1;
440
- }
441
-
442
- if (direction === 'down') {
443
- let next = start + 1;
444
-
445
- if (next > computedOptions.value.length - 1) {
446
- next = 0;
447
- }
448
-
449
- selectedIndex.value = next;
450
- } else {
451
- let next = start - 1;
452
-
453
- if (next < 0) {
454
- next = computedOptions.value.length - 1;
455
- }
456
-
457
- selectedIndex.value = next;
458
- }
459
- }
460
-
461
- function handleOptionClick(value: string | number) {
462
- const option = computedOptions.value.find(
463
- item => item.value === value
464
- );
465
- if (!option) {
466
- return;
467
- }
468
-
469
- if (props.multiple) {
470
- if (Array.isArray(selected.value)) {
471
- const index = selected.value.indexOf(value);
472
-
473
- if (index !== -1) {
474
- selected.value.splice(index, 1);
475
- } else {
476
- selected.value.push(value);
477
- emit('update:modelValue', selected.value);
478
- }
479
- } else {
480
- selected.value = [value];
481
- }
482
-
483
- updatePopper();
484
- } else {
485
- selected.value = value;
486
- open.value = false;
487
- }
488
- }
489
-
490
- function removeSelected(event: MouseEvent, value: string | number) {
491
- event.preventDefault();
492
- event.stopPropagation();
493
- if (!props.multiple) {
494
- return;
495
- }
496
-
497
- if (Array.isArray(selected.value)) {
498
- const index = selected.value.indexOf(value);
499
-
500
- if (index !== -1) {
501
- selected.value.splice(index, 1);
502
- }
503
-
504
- updatePopper();
505
- }
506
- }
507
-
508
- useEventListener('resize', () => {
509
- open.value = false;
510
- });
511
-
512
- function handleKeyDown(e: Event) {
513
- if (computedOptions.value.length === 0) {
514
- return;
515
- }
516
-
517
- if (props.disabled) {
518
- return;
519
- }
520
-
521
- keyboardNavigation.value = true;
522
-
523
- const event: KeyboardEvent = e as KeyboardEvent;
524
-
525
- switch (event.code) {
526
- case 'Tab':
527
- if (open.value) {
528
- open.value = false;
529
- }
530
- break;
531
- case 'ArrowDown':
532
- e.preventDefault();
533
- if (!open.value) {
534
- open.value = true;
535
- return;
536
- }
537
-
538
- getSelectedIndex(selectedIndex.value, 'down');
539
- break;
540
- case 'ArrowUp':
541
- e.preventDefault();
542
- if (!open.value) {
543
- return;
544
- }
545
-
546
- getSelectedIndex(selectedIndex.value, 'up');
547
- break;
548
- case 'Enter':
549
- e.preventDefault();
550
- e.stopPropagation();
551
-
552
- if (!open.value) {
553
- open.value = true;
554
- return;
555
- }
556
-
557
- if (typeof selectedIndex.value !== 'undefined') {
558
- handleOptionClick(
559
- computedOptions.value[selectedIndex.value].value
560
- );
561
- if (!props.multiple) {
562
- open.value = false;
563
- }
564
- }
565
- break;
566
- case 'Escape':
567
- e.preventDefault();
568
- e.stopPropagation();
569
-
570
- if (!open.value) {
571
- return;
572
- }
573
-
574
- open.value = false;
575
- break;
576
- }
577
- }
578
-
579
- const transitionName = computed(() => {
580
- return `m-transition__${props.transitionName}`;
581
- });
582
-
583
- const containerComputedClasses = computed(() => {
584
- return [
585
- 'm-select__container',
586
- {
587
- 'm-form__input': insideForm
588
- }
589
- ];
590
- });
591
-
592
- const computedWrapperClasses = computed(() => {
593
- return [
594
- style.wrapper,
595
- {
596
- [style.focused]: focused.value
597
- }
598
- ];
599
- });
600
-
601
- const selectComputedClasses = computed(() => {
602
- return [
603
- style.base,
604
- {
605
- [style.selectedTags]:
606
- props.multiple &&
607
- Array.isArray(selected.value) &&
608
- selected.value.length > 0,
609
- [style.focused]: open.value,
610
- // [`m-select--${props.size}`]: props.size,
611
- 'm-select--disabled': disabled.value || loading.value,
612
- 'm-select--focused': open.value,
613
- 'm-select--multiple': props.multiple,
614
- 'm-select--error': formError.value
615
- }
616
- ];
617
- });
618
-
619
- function getSelectedLabel(value: string | number | []) {
620
- const option = props.options.find(
621
- i => i[props.optionValue] === value
622
- );
623
- if (!option) {
624
- return null;
625
- }
626
-
627
- return option[props.optionLabel];
628
- }
629
-
630
- useEventListener(selectRef, 'keydown', handleKeyDown);
631
- useEventListener(searchRef, 'keydown', handleKeyDown);
632
-
633
- onClickOutside(
634
- container,
635
- () => {
636
- open.value = false;
637
- },
638
- { ignore: [searchRef, popper] }
639
- );
640
-
641
- watchEffect(() => {
642
- if (container.value && open.value) {
643
- width.value = container.value.offsetWidth;
644
- }
645
- });
646
-
647
- watch(selectedIndex, (index: number | undefined) => {
648
- if (!keyboardNavigation.value) {
649
- return;
650
- }
651
- if (typeof index !== 'undefined' && optionsRef.value) {
652
- optionsRef.value[index].scrollIntoView({
653
- block: 'nearest',
654
- inline: 'nearest'
655
- });
656
- }
657
- });
658
-
659
- watch(open, (value: boolean) => {
660
- nextTick(() => {
661
- if (!value) {
662
- search.value = '';
663
- }
664
-
665
- if (props.searchable && searchRef.value) {
666
- searchRef.value.focus();
667
- }
668
- if (value && optionsRef.value) {
669
- const index = computedOptions.value.findIndex(
670
- i => i.value === selected.value
671
- );
672
- if (index !== -1) {
673
- optionsRef.value[index].scrollIntoView({
674
- block: 'nearest'
675
- });
676
- selectedIndex.value = index;
677
- }
678
- }
679
-
680
- keyboardNavigation.value = false;
681
- });
682
- });
683
-
684
- const inputError = computed(() => {
685
- return formError.value || props.error;
686
- });
687
-
688
- const isOpen = computed(() => {
689
- if (
690
- props.multiple &&
691
- computedOptions.value.length == 0 &&
692
- !search.value
693
- ) {
694
- return false;
695
- }
696
- return open.value;
697
- });
698
-
699
- const focusSelect = (event: FocusEvent) => {
700
- setTimeout(() => {
701
- if (selectRef.value && !open.value) {
702
- openSelect();
703
- }
704
- }, 10);
705
- };
706
-
707
- const onSelectClick = (event: MouseEvent) => {
708
- event.preventDefault();
709
- event.stopPropagation();
710
- openSelect();
711
- };
712
-
713
- expose({
714
- focus,
715
- blur
716
- });
717
-
718
- return {
719
- container,
720
- optionsRef,
721
- searchRef,
722
- selectRef,
723
- popper,
724
- focusSelect,
725
- onSelectClick,
726
-
727
- id,
728
- isOpen,
729
- open,
730
- selected,
731
- selectedMax,
732
- selectedCountNotShown,
733
- computedOptions,
734
- selectedIndex,
735
- search,
736
-
737
- containerComputedClasses,
738
- selectComputedClasses,
739
- computedWrapperClasses,
740
- computedConsistentWidth,
741
-
742
- getSelectedLabel,
743
- openSelect,
744
- removeSelected,
745
- inputError,
746
- transitionName,
747
- inputListeners,
748
- style,
749
- focused,
750
- scale,
751
- toggleAllItems
752
- };
753
- }
754
- });
755
- </script>
1
+ <template>
2
+ <div ref="selectRef" tabindex="-1">
3
+ <label v-if="label" :for="id" :class="style.label" :id="`${id}-label`">
4
+ {{ label }}
5
+ </label>
6
+ <div v-if="description" :class="style.description">
7
+ {{ description }}
8
+ </div>
9
+
10
+ <div :class="computedWrapperClasses" v-on="inputListeners" :id="id" ref="container"
11
+ :aria-disabled="disabled ? 'true' : 'false'" :aria-labelledby="`${id}-label`" tabindex="0"
12
+ @mousedown.prevent="onSelectClick" @focusin.stop="focusSelect">
13
+ <div :class="selectComputedClasses">
14
+ <template v-if="
15
+ multiple &&
16
+ Array.isArray(selected) &&
17
+ selected.length > 0
18
+ ">
19
+ <MTag v-for="value in selectedMax" :key="value" :closeable="true"
20
+ @close="event => removeSelected(event, value)" :size="size">
21
+ {{ getSelectedLabel(value) }}
22
+ </MTag>
23
+ <template v-if="selectedCountNotShown > 0">
24
+ <MTag :size="size">
25
+ +{{ selectedCountNotShown }} more
26
+ </MTag>
27
+ </template>
28
+ </template>
29
+ <template v-else-if="!multiple && selected">
30
+ {{ getSelectedLabel(selected) }}
31
+ </template>
32
+ <template v-else>
33
+ <div :class="style.placeholder">{{ placeholder }}</div>
34
+ </template>
35
+ </div>
36
+
37
+ <Teleport :to="'body'">
38
+ <Transition :enter-active-class="scale.enterActive" :leave-active-class="scale.leaveActive"
39
+ :enter-from-class="scale.enterFrom" :leave-to-class="scale.leaveTo">
40
+ <div :class="style.menu" v-if="isOpen" ref="popper" :style="computedConsistentWidth"
41
+ :aria-hidden="isOpen ? 'false' : 'true'">
42
+ <div v-if="searchable || (multiple && selectAll)" :class="style.search">
43
+ <div v-if="multiple && selectAll" :class="style.checkAll">
44
+ <button role="button" type="button" :class="{
45
+ [style.checkAllButton]: true,
46
+ [style.checkAllButtonActive]:
47
+ computedOptions.length > 0 &&
48
+ computedOptions.length ===
49
+ selected.length
50
+ }" @click.prevent="toggleAllItems" aria-description="Select all items">
51
+ <svg v-if="
52
+ computedOptions.length > 0 &&
53
+ computedOptions.length ===
54
+ selected.length
55
+ " xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
56
+ aria-hidden="true">
57
+ <path fill-rule="evenodd"
58
+ d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
59
+ clip-rule="evenodd"></path>
60
+ </svg>
61
+ </button>
62
+ </div>
63
+ <input v-if="searchable" type="text" ref="searchRef" v-model="search" placeholder="Search"
64
+ :class="style.searchInput" />
65
+ </div>
66
+ <div :class="style.options">
67
+ <div v-for="(option, index) in computedOptions" :key="index" ref="optionsRef" :class="{
68
+ [style.option]: true,
69
+ [style.optionSelected]: option.active,
70
+ [style.optionFocused]:
71
+ selectedIndex === index
72
+ }" @click.prevent="option.onClick" @hover="selectedIndex = index">
73
+ <span :class="style.optionIcon">
74
+ <svg v-if="option.active" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
75
+ fill="currentColor" aria-hidden="true">
76
+ <path fill-rule="evenodd"
77
+ d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
78
+ clip-rule="evenodd"></path>
79
+ </svg>
80
+ </span>
81
+ <span :class="style.optionLabel">{{
82
+ option.label
83
+ }}</span>
84
+ </div>
85
+ <template v-if="computedOptions.length === 0">
86
+ <div :class="style.noResults">No results</div>
87
+ </template>
88
+ </div>
89
+ </div>
90
+ </Transition>
91
+ </Teleport>
92
+ </div>
93
+ <div v-if="inputError" :class="style.errorMessage">
94
+ {{ inputError }}
95
+ </div>
96
+ </div>
97
+ </template>
98
+
99
+ <script lang="ts">
100
+ import { useInput } from '../../composables/useInput';
101
+ import { usePopper, type Placement } from '../../composables/usePopper';
102
+ import type { MaybeArray, Size } from '../../types/props';
103
+ import { onClickOutside, useEventListener } from '@vueuse/core';
104
+ import {
105
+ computed,
106
+ defineComponent,
107
+ nextTick,
108
+ ref,
109
+ watch,
110
+ watchEffect,
111
+ type PropType
112
+ } from 'vue';
113
+ import MTag from '../tag/Tag.vue';
114
+ import * as style from './style';
115
+ import { scale } from '../../styles/transitions.css';
116
+ import { useInteractive } from '../../composables/useInteractive';
117
+ import type { OnUpdateValue, OnUpdateValueImpl } from './types';
118
+ import { call } from "../../utils/call";
119
+ import {useZIndex} from "../../composables/useZIndex";
120
+
121
+ export default defineComponent({
122
+ name: 'MSelect',
123
+ emits: {
124
+ ...useInput.emits()
125
+ },
126
+ props: {
127
+ ...useInput.props(),
128
+ ...useInteractive.props(),
129
+ label: {
130
+ type: String
131
+ },
132
+ placeholder: {
133
+ type: String,
134
+ default: 'Select an option'
135
+ },
136
+ options: {
137
+ type: Array as PropType<Array<any>>,
138
+ required: true
139
+ },
140
+ searchable: {
141
+ type: Boolean,
142
+ default: false
143
+ },
144
+ multiple: {
145
+ type: Boolean,
146
+ default: false
147
+ },
148
+ size: {
149
+ type: String as PropType<Size>,
150
+ default: 'md'
151
+ },
152
+ description: {
153
+ type: String,
154
+ default: ''
155
+ },
156
+ optionValue: {
157
+ type: String,
158
+ default: 'value'
159
+ },
160
+ optionLabel: {
161
+ type: String,
162
+ default: 'label'
163
+ },
164
+ transitionName: {
165
+ type: String,
166
+ default: 'fade'
167
+ },
168
+ consistentWidth: {
169
+ type: Boolean,
170
+ default: true
171
+ },
172
+ disabled: {
173
+ type: Boolean,
174
+ default: false
175
+ },
176
+ loading: {
177
+ type: Boolean,
178
+ default: false
179
+ },
180
+ error: {
181
+ type: String
182
+ },
183
+ allowBlank: {
184
+ type: Boolean,
185
+ default: false
186
+ },
187
+ selectAll: {
188
+ type: Boolean,
189
+ default: false
190
+ },
191
+ max: {
192
+ type: Number,
193
+ default: 5
194
+ },
195
+ zIndex: {
196
+ type: Number
197
+ },
198
+ 'onUpdate:value': [Function, Array] as PropType<MaybeArray<OnUpdateValue> | undefined>,
199
+ onUpdateValue: [Function, Array] as PropType<MaybeArray<OnUpdateValue> | undefined>,
200
+ },
201
+ components: {
202
+ MTag
203
+ },
204
+ setup(props, { emit, expose }) {
205
+ const container = ref<HTMLElement | null>(null);
206
+ const wrapper = ref<HTMLElement>();
207
+ const optionsRef = ref<HTMLElement[]>();
208
+ const searchRef = ref<HTMLElement>();
209
+ const selectRef = ref<HTMLElement>();
210
+ const selectedIndex = ref<number | undefined>(0);
211
+ const open = ref<boolean>(false);
212
+ const focused = ref<boolean>(false);
213
+ const search = ref<string>('');
214
+ const placement = ref<Placement>('bottom-start');
215
+ const transfer = ref(false);
216
+ const width = ref<number>(0);
217
+ const keyboardNavigation = ref<boolean>(false);
218
+ const {nextZIndex} = useZIndex();
219
+ function doUpdateValue(
220
+ value: string | number | Array<string | number> | null,
221
+ option: object | null
222
+ ): void {
223
+ const {
224
+ 'onUpdate:value': _onUpdateValue,
225
+ onUpdateValue
226
+ } = props;
227
+
228
+ if (onUpdateValue) call(onUpdateValue as OnUpdateValueImpl, value, option)
229
+ if (_onUpdateValue) {
230
+ call(_onUpdateValue as OnUpdateValueImpl, value, option)
231
+ }
232
+ }
233
+
234
+ const zIndex = ref(props.zIndex ?? nextZIndex());
235
+
236
+
237
+ const { focus, blur } = useInteractive(container);
238
+ // TODO: Add debounce on keyboard movement (hover flicks it back up)
239
+ const {
240
+ id,
241
+ insideForm,
242
+ inputListeners,
243
+ formError,
244
+ formDisabled,
245
+ formLoading
246
+ } = useInput(props, { focus, emit, withListeners: false });
247
+
248
+ const { popper, updatePopper } = usePopper({
249
+ placement,
250
+ transfer,
251
+ reference: container,
252
+ wrapper,
253
+ offset: [0, 5]
254
+ });
255
+
256
+ const disabled = computed(() => formDisabled || props.disabled);
257
+ const loading = computed(() => formLoading || props.loading);
258
+
259
+ const selected = computed<any | any[]>({
260
+ get() {
261
+ if (props.multiple) {
262
+ if (!props.modelValue) {
263
+ return [];
264
+ }
265
+
266
+ if (Array.isArray(props.modelValue)) {
267
+ return props.modelValue;
268
+ }
269
+
270
+ return [props.modelValue];
271
+ }
272
+
273
+ return props.modelValue || null;
274
+ },
275
+ set(value: string | number | []) {
276
+ emit('update:modelValue', value);
277
+ doUpdateValue(value, null);
278
+ }
279
+ });
280
+
281
+ const selectedMax = computed(() => {
282
+ if (props.max <= 0) {
283
+ return selected.value;
284
+ }
285
+
286
+ if (
287
+ props.multiple &&
288
+ Array.isArray(selected.value) &&
289
+ selected.value.length > props.max
290
+ ) {
291
+ return selected.value.slice(0, props.max);
292
+ }
293
+
294
+ return selected.value;
295
+ });
296
+
297
+ const selectedCountNotShown = computed(() => {
298
+ if (
299
+ props.multiple &&
300
+ Array.isArray(selected.value) &&
301
+ props.max > 0
302
+ ) {
303
+ return selected.value.length - props.max;
304
+ }
305
+
306
+ return 0;
307
+ });
308
+
309
+ const computedStyle = computed(() => {
310
+ let styles = {};
311
+ if (props.consistentWidth) {
312
+ // @ts-ignore
313
+ styles.width = width.value + 'px';
314
+ }
315
+
316
+ return {
317
+ ...styles,
318
+ zIndex: zIndex.value
319
+ };
320
+ });
321
+
322
+ const computedOptions = computed(() => {
323
+ if (!props.options || props.options.length == 0) {
324
+ return [];
325
+ }
326
+
327
+ // Filter options by search
328
+ if (props.searchable && search.value) {
329
+ let options = props.options
330
+ .filter(option => {
331
+ return option[props.optionLabel]
332
+ .toLowerCase()
333
+ .includes(search.value.toLowerCase());
334
+ })
335
+ .map(option => {
336
+ if (option[props.optionValue] === undefined) {
337
+ throw new Error('Option value is undefined');
338
+ }
339
+
340
+ if (option[props.optionLabel] === undefined) {
341
+ throw new Error('Option label is undefined');
342
+ }
343
+
344
+ let value = option[props.optionValue];
345
+ let active: boolean;
346
+ if (props.multiple && Array.isArray(selected.value)) {
347
+ active = selected.value.includes(value);
348
+ } else {
349
+ active = selected.value === value;
350
+ }
351
+
352
+ return {
353
+ value: value,
354
+ label: option[props.optionLabel],
355
+ active: active,
356
+ onClick: () => handleOptionClick(value)
357
+ };
358
+ });
359
+
360
+ if (props.allowBlank) {
361
+ options.unshift({
362
+ value: '',
363
+ label: '',
364
+ active: false,
365
+ onClick: () => handleOptionClick('')
366
+ });
367
+ }
368
+
369
+ return options;
370
+ }
371
+
372
+ let options = props.options.map(option => {
373
+ if (option[props.optionValue] === undefined) {
374
+ throw new Error('Option value is undefined');
375
+ }
376
+
377
+ if (option[props.optionLabel] === undefined) {
378
+ throw new Error('Option label is undefined');
379
+ }
380
+
381
+ let value = option[props.optionValue];
382
+ let active: boolean;
383
+ if (props.multiple && Array.isArray(selected.value)) {
384
+ active = selected.value.includes(value);
385
+ } else {
386
+ active = selected.value === value;
387
+ }
388
+
389
+ return {
390
+ value: value,
391
+ label: option[props.optionLabel],
392
+ active: active,
393
+ onClick: () => handleOptionClick(value)
394
+ };
395
+ });
396
+
397
+ if (props.allowBlank) {
398
+ options.unshift({
399
+ value: '',
400
+ label: '',
401
+ active: false,
402
+ onClick: () => handleOptionClick('')
403
+ });
404
+ }
405
+
406
+ return options;
407
+ });
408
+
409
+ function toggleAllItems() {
410
+ if (!props.multiple) {
411
+ return;
412
+ }
413
+
414
+ if (computedOptions.value.length === 0) {
415
+ return;
416
+ }
417
+
418
+ if (Array.isArray(selected.value)) {
419
+ if (selected.value.length === computedOptions.value.length) {
420
+ selected.value = [];
421
+ } else {
422
+ selected.value = computedOptions.value.map(
423
+ option => option.value
424
+ );
425
+ }
426
+ } else {
427
+ selected.value = [];
428
+ }
429
+ }
430
+
431
+ function openSelect() {
432
+ if (disabled.value || loading.value) {
433
+ return;
434
+ }
435
+
436
+ if (open.value) {
437
+ open.value = false;
438
+ return;
439
+ }
440
+ open.value = true;
441
+ }
442
+
443
+ function getSelectedIndex(
444
+ start: number | undefined,
445
+ direction = 'down'
446
+ ) {
447
+ if (typeof start === 'undefined') {
448
+ start = direction === 'down' ? -1 : 1;
449
+ }
450
+
451
+ if (direction === 'down') {
452
+ let next = start + 1;
453
+
454
+ if (next > computedOptions.value.length - 1) {
455
+ next = 0;
456
+ }
457
+
458
+ selectedIndex.value = next;
459
+ } else {
460
+ let next = start - 1;
461
+
462
+ if (next < 0) {
463
+ next = computedOptions.value.length - 1;
464
+ }
465
+
466
+ selectedIndex.value = next;
467
+ }
468
+ }
469
+
470
+ function handleOptionClick(value: string | number) {
471
+ const option = computedOptions.value.find(
472
+ item => item.value === value
473
+ );
474
+ if (!option) {
475
+ return;
476
+ }
477
+
478
+ if (props.multiple) {
479
+ if (Array.isArray(selected.value)) {
480
+ const index = selected.value.indexOf(value);
481
+
482
+ if (index !== -1) {
483
+ selected.value.splice(index, 1);
484
+ } else {
485
+ selected.value.push(value);
486
+ emit('update:modelValue', selected.value);
487
+ }
488
+ } else {
489
+ selected.value = [value];
490
+ }
491
+
492
+ updatePopper();
493
+ } else {
494
+ selected.value = value;
495
+ open.value = false;
496
+ }
497
+ }
498
+
499
+ function removeSelected(event: MouseEvent, value: string | number) {
500
+ event.preventDefault();
501
+ event.stopPropagation();
502
+ if (!props.multiple) {
503
+ return;
504
+ }
505
+
506
+ if (Array.isArray(selected.value)) {
507
+ const index = selected.value.indexOf(value);
508
+
509
+ if (index !== -1) {
510
+ selected.value.splice(index, 1);
511
+ }
512
+
513
+ updatePopper();
514
+ }
515
+ }
516
+
517
+ useEventListener('resize', () => {
518
+ open.value = false;
519
+ });
520
+
521
+ function handleKeyDown(e: Event) {
522
+ if (computedOptions.value.length === 0) {
523
+ return;
524
+ }
525
+
526
+ if (props.disabled) {
527
+ return;
528
+ }
529
+
530
+ keyboardNavigation.value = true;
531
+
532
+ const event: KeyboardEvent = e as KeyboardEvent;
533
+
534
+ switch (event.code) {
535
+ case 'Tab':
536
+ if (open.value) {
537
+ open.value = false;
538
+ }
539
+ break;
540
+ case 'ArrowDown':
541
+ e.preventDefault();
542
+ if (!open.value) {
543
+ open.value = true;
544
+ return;
545
+ }
546
+
547
+ getSelectedIndex(selectedIndex.value, 'down');
548
+ break;
549
+ case 'ArrowUp':
550
+ e.preventDefault();
551
+ if (!open.value) {
552
+ return;
553
+ }
554
+
555
+ getSelectedIndex(selectedIndex.value, 'up');
556
+ break;
557
+ case 'Enter':
558
+ e.preventDefault();
559
+ e.stopPropagation();
560
+
561
+ if (!open.value) {
562
+ open.value = true;
563
+ return;
564
+ }
565
+
566
+ if (typeof selectedIndex.value !== 'undefined') {
567
+ handleOptionClick(
568
+ computedOptions.value[selectedIndex.value].value
569
+ );
570
+ if (!props.multiple) {
571
+ open.value = false;
572
+ }
573
+ }
574
+ break;
575
+ case 'Escape':
576
+ e.preventDefault();
577
+ e.stopPropagation();
578
+
579
+ if (!open.value) {
580
+ return;
581
+ }
582
+
583
+ open.value = false;
584
+ break;
585
+ }
586
+ }
587
+
588
+ const transitionName = computed(() => {
589
+ return `m-transition__${props.transitionName}`;
590
+ });
591
+
592
+ const containerComputedClasses = computed(() => {
593
+ return [
594
+ 'm-select__container',
595
+ {
596
+ 'm-form__input': insideForm
597
+ }
598
+ ];
599
+ });
600
+
601
+ const computedWrapperClasses = computed(() => {
602
+ return [
603
+ style.wrapper,
604
+ {
605
+ [style.focused]: focused.value
606
+ }
607
+ ];
608
+ });
609
+
610
+ const selectComputedClasses = computed(() => {
611
+ return [
612
+ style.base,
613
+ {
614
+ [style.selectedTags]:
615
+ props.multiple &&
616
+ Array.isArray(selected.value) &&
617
+ selected.value.length > 0,
618
+ [style.focused]: open.value,
619
+ // [`m-select--${props.size}`]: props.size,
620
+ 'm-select--disabled': disabled.value || loading.value,
621
+ 'm-select--focused': open.value,
622
+ 'm-select--multiple': props.multiple,
623
+ 'm-select--error': formError.value
624
+ }
625
+ ];
626
+ });
627
+
628
+ function getSelectedLabel(value: string | number | []) {
629
+ const option = props.options.find(
630
+ i => i[props.optionValue] === value
631
+ );
632
+ if (!option) {
633
+ return null;
634
+ }
635
+
636
+ return option[props.optionLabel];
637
+ }
638
+
639
+ useEventListener(selectRef, 'keydown', handleKeyDown);
640
+ useEventListener(searchRef, 'keydown', handleKeyDown);
641
+
642
+ onClickOutside(
643
+ container,
644
+ () => {
645
+ open.value = false;
646
+ },
647
+ { ignore: [searchRef, popper] }
648
+ );
649
+
650
+ watchEffect(() => {
651
+ if (container.value && open.value) {
652
+ width.value = container.value.offsetWidth;
653
+ }
654
+ });
655
+
656
+ watch(selectedIndex, (index: number | undefined) => {
657
+ if (!keyboardNavigation.value) {
658
+ return;
659
+ }
660
+ if (typeof index !== 'undefined' && optionsRef.value) {
661
+ optionsRef.value[index].scrollIntoView({
662
+ block: 'nearest',
663
+ inline: 'nearest'
664
+ });
665
+ }
666
+ });
667
+
668
+ watch(open, (value: boolean) => {
669
+ nextTick(() => {
670
+ if (!value) {
671
+ search.value = '';
672
+ }
673
+
674
+ if (props.searchable && searchRef.value) {
675
+ searchRef.value.focus();
676
+ }
677
+ if (value && optionsRef.value) {
678
+ const index = computedOptions.value.findIndex(
679
+ i => i.value === selected.value
680
+ );
681
+ if (index !== -1) {
682
+ optionsRef.value[index].scrollIntoView({
683
+ block: 'nearest'
684
+ });
685
+ selectedIndex.value = index;
686
+ }
687
+ }
688
+
689
+ keyboardNavigation.value = false;
690
+ });
691
+ });
692
+
693
+ const inputError = computed(() => {
694
+ return formError.value || props.error;
695
+ });
696
+
697
+ const isOpen = computed(() => {
698
+ if (
699
+ props.multiple &&
700
+ computedOptions.value.length == 0 &&
701
+ !search.value
702
+ ) {
703
+ return false;
704
+ }
705
+ return open.value;
706
+ });
707
+
708
+ const focusSelect = (event: FocusEvent) => {
709
+ setTimeout(() => {
710
+ if (selectRef.value && !open.value) {
711
+ openSelect();
712
+ }
713
+ }, 10);
714
+ };
715
+
716
+ const onSelectClick = (event: MouseEvent) => {
717
+ event.preventDefault();
718
+ event.stopPropagation();
719
+ openSelect();
720
+ };
721
+
722
+ expose({
723
+ focus,
724
+ blur
725
+ });
726
+
727
+ return {
728
+ container,
729
+ optionsRef,
730
+ searchRef,
731
+ selectRef,
732
+ popper,
733
+ focusSelect,
734
+ onSelectClick,
735
+
736
+ id,
737
+ isOpen,
738
+ open,
739
+ selected,
740
+ selectedMax,
741
+ selectedCountNotShown,
742
+ computedOptions,
743
+ selectedIndex,
744
+ search,
745
+
746
+ containerComputedClasses,
747
+ selectComputedClasses,
748
+ computedWrapperClasses,
749
+ computedConsistentWidth: computedStyle,
750
+
751
+ getSelectedLabel,
752
+ openSelect,
753
+ removeSelected,
754
+ inputError,
755
+ transitionName,
756
+ inputListeners,
757
+ style,
758
+ focused,
759
+ scale,
760
+ toggleAllItems
761
+ };
762
+ }
763
+ });
764
+ </script>