@teammodlr/ui-kit 2.1.45 → 2.1.46

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