@teammodlr/ui-kit 3.0.0-beta.9 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/lib/components/cardcontextmenu/CardContextMenu.vue.d.ts +4 -2
  2. package/lib/components/checkbox/Checkbox.vue.d.ts +4 -4
  3. package/lib/components/clearbutton/ClearButton.vue.d.ts +9 -0
  4. package/lib/components/clearbutton/style/ClearButton.css.d.ts +2 -0
  5. package/lib/components/clearbutton/style/index.d.ts +1 -0
  6. package/lib/components/colorpicker/ColorPicker.vue.d.ts +6 -4
  7. package/lib/components/colorpicker/style/ColorPicker.css.d.ts +1 -0
  8. package/lib/components/dialog/Dialog.vue.d.ts +14 -6
  9. package/lib/components/dialog/style/Dialog.css.d.ts +2 -0
  10. package/lib/components/drawer/Drawer.vue.d.ts +146 -0
  11. package/lib/components/drawer/style/Drawer.css.d.ts +33 -0
  12. package/lib/components/drawer/style/index.d.ts +1 -0
  13. package/lib/components/dropdown/Dropdown.vue.d.ts +4 -2
  14. package/lib/components/form/Form.vue.d.ts +4 -2
  15. package/lib/components/hovercard/HoverCard.vue.d.ts +124 -0
  16. package/lib/components/hovercard/style/HoverCard.css.d.ts +3 -0
  17. package/lib/components/hovercard/style/index.d.ts +1 -0
  18. package/lib/components/index.d.ts +3 -0
  19. package/lib/components/input/Input.vue.d.ts +20 -5
  20. package/lib/components/input/style/Input.css.d.ts +4 -0
  21. package/lib/components/modal/Modal.vue.d.ts +11 -4
  22. package/lib/components/pagination/Pagination.vue.d.ts +6 -3
  23. package/lib/components/paneldialog/PanelDialog.vue.d.ts +175 -0
  24. package/lib/components/paneldialog/style/PanelDialog.css.d.ts +8 -0
  25. package/lib/components/paneldialog/style/index.d.ts +1 -0
  26. package/lib/components/popover/Popover.vue.d.ts +28 -2
  27. package/lib/components/radio/Radio.vue.d.ts +5 -5
  28. package/lib/components/radiobutton/RadioButton.vue.d.ts +4 -4
  29. package/lib/components/radiobuttongroup/RadioButtonGroup.vue.d.ts +4 -2
  30. package/lib/components/select/Select.vue.d.ts +94 -91
  31. package/lib/components/select/style/Select.css.d.ts +9 -1
  32. package/lib/components/slider/Slider.vue.d.ts +6 -3
  33. package/lib/components/switch/Switch.vue.d.ts +4 -4
  34. package/lib/components/tab/Tab.vue.d.ts +10 -2
  35. package/lib/components/tab/TabGroup.vue.d.ts +6 -3
  36. package/lib/components/table/Table.vue.d.ts +4 -2
  37. package/lib/components/textarea/Textarea.vue.d.ts +16 -5
  38. package/lib/components/textarea/style/Textarea.css.d.ts +2 -0
  39. package/lib/components/tree/Tree.vue.d.ts +6 -3
  40. package/lib/composables/useInput.d.ts +9 -2
  41. package/lib/index.cjs.js +3 -3
  42. package/lib/index.es.js +8526 -6393
  43. package/lib/style.css +1 -1
  44. package/lib/tsconfig.app.tsbuildinfo +1 -0
  45. package/lib/tsconfig.vitest.tsbuildinfo +1 -0
  46. package/package.json +7 -4
  47. package/src/components/cardcontextmenu/CardContextMenu.vue +3 -1
  48. package/src/components/cardcontextmenu/style/Dropdown.css.ts +7 -0
  49. package/src/components/clearbutton/ClearButton.vue +41 -0
  50. package/src/components/clearbutton/style/ClearButton.css.ts +37 -0
  51. package/src/components/clearbutton/style/index.ts +1 -0
  52. package/src/components/colorpicker/ColorPicker.vue +12 -12
  53. package/src/components/colorpicker/examples/basic.vue +8 -17
  54. package/src/components/colorpicker/style/ColorPicker.css.ts +6 -1
  55. package/src/components/dialog/Dialog.vue +12 -6
  56. package/src/components/dialog/style/Dialog.css.ts +30 -0
  57. package/src/components/drawer/Drawer.vue +280 -0
  58. package/src/components/drawer/examples/basic.vue +15 -0
  59. package/src/components/drawer/examples/custom-title.vue +21 -0
  60. package/src/components/drawer/examples/index.stories.ts +24 -0
  61. package/src/components/drawer/examples/index.ts +7 -0
  62. package/src/components/drawer/examples/no-mask.vue +15 -0
  63. package/src/components/drawer/examples/no-outside-close.vue +15 -0
  64. package/src/components/drawer/examples/placements.vue +28 -0
  65. package/src/components/drawer/examples/with-footer.vue +21 -0
  66. package/src/components/drawer/examples/with-trigger.vue +13 -0
  67. package/src/components/drawer/style/Drawer.css.ts +208 -0
  68. package/src/components/drawer/style/index.ts +1 -0
  69. package/src/components/dropdown/Dropdown.vue +4 -2
  70. package/src/components/form/Form.vue +3 -3
  71. package/src/components/hovercard/HoverCard.vue +165 -0
  72. package/src/components/hovercard/style/HoverCard.css.ts +175 -0
  73. package/src/components/hovercard/style/index.ts +1 -0
  74. package/src/components/index.ts +3 -0
  75. package/src/components/input/Input.vue +52 -4
  76. package/src/components/input/examples/clearable.vue +18 -0
  77. package/src/components/input/examples/index.stories.ts +3 -1
  78. package/src/components/input/examples/index.ts +1 -0
  79. package/src/components/input/style/Input.css.ts +19 -0
  80. package/src/components/messageprovider/MessageProvider.vue +33 -7
  81. package/src/components/modal/Modal.vue +28 -11
  82. package/src/components/pagination/Pagination.vue +4 -1
  83. package/src/components/paneldialog/PanelDialog.vue +509 -0
  84. package/src/components/paneldialog/examples/basic.vue +36 -0
  85. package/src/components/paneldialog/examples/index.stories.ts +22 -0
  86. package/src/components/paneldialog/examples/index.ts +2 -0
  87. package/src/components/paneldialog/examples/modal.vue +31 -0
  88. package/src/components/paneldialog/style/PanelDialog.css.ts +90 -0
  89. package/src/components/paneldialog/style/index.ts +1 -0
  90. package/src/components/popover/Popover.vue +9 -2
  91. package/src/components/radiobuttongroup/RadioButtonGroup.vue +3 -1
  92. package/src/components/select/Select.vue +211 -111
  93. package/src/components/select/examples/clearable.vue +18 -0
  94. package/src/components/select/examples/fill.vue +52 -0
  95. package/src/components/select/examples/index.stories.ts +4 -1
  96. package/src/components/select/examples/index.ts +3 -0
  97. package/src/components/select/examples/remote.vue +54 -0
  98. package/src/components/select/style/Select.css.ts +140 -24
  99. package/src/components/slider/Slider.vue +4 -1
  100. package/src/components/tab/Tab.vue +4 -2
  101. package/src/components/tab/TabGroup.vue +4 -4
  102. package/src/components/tab/style/Tab.css.ts +6 -0
  103. package/src/components/table/Table.vue +3 -3
  104. package/src/components/textarea/Textarea.vue +40 -4
  105. package/src/components/textarea/examples/clearable.vue +16 -0
  106. package/src/components/textarea/examples/index.stories.ts +2 -1
  107. package/src/components/textarea/examples/index.ts +1 -0
  108. package/src/components/textarea/style/Textarea.css.ts +10 -0
  109. package/src/components/tree/Tree.vue +4 -1
  110. package/src/composables/useInput.ts +10 -2
@@ -9,6 +9,7 @@
9
9
  :side="resolvedSide"
10
10
  :align="resolvedAlign"
11
11
  :side-offset="sideOffset"
12
+ :collision-padding="collisionPadding"
12
13
  :style="{ zIndex: zIndex }"
13
14
  >
14
15
  <slot name="content" />
@@ -68,12 +69,16 @@ export default defineComponent({
68
69
  },
69
70
  transfer: {
70
71
  type: Boolean,
71
- default: false
72
+ default: true
72
73
  },
73
74
  sideOffset: {
74
75
  type: Number,
75
76
  default: 5
76
77
  },
78
+ collisionPadding: {
79
+ type: [Number, Object] as PropType<number | { top?: number; right?: number; bottom?: number; left?: number }>,
80
+ default: 10
81
+ },
77
82
  arrow: {
78
83
  type: Boolean,
79
84
  default: false
@@ -86,7 +91,9 @@ export default defineComponent({
86
91
  type: Number
87
92
  }
88
93
  },
89
- emits: ['update:open'],
94
+ emits: {
95
+ 'update:open': (_value: boolean) => true
96
+ },
90
97
  setup(props, { emit }) {
91
98
  const attrs = useAttrs();
92
99
  const { nextZIndex } = useZIndex();
@@ -39,7 +39,9 @@ export default defineComponent({
39
39
  default: false
40
40
  }
41
41
  },
42
- emits: ['update:modelValue'],
42
+ emits: {
43
+ 'update:modelValue': (_value: any) => true
44
+ },
43
45
  setup(props, {emit}) {
44
46
 
45
47
  function doUpdateValue (value: string | number) {
@@ -1,6 +1,6 @@
1
1
  <template>
2
2
  <PopoverRoot :open="isOpen" @update:open="handleOpenChange">
3
- <div ref="selectRef" tabindex="-1">
3
+ <div ref="selectRef" tabindex="-1" :class="fillRootClass">
4
4
  <label v-if="label" :for="id" :class="style.label" :id="`${id}-label`">
5
5
  {{ label }}
6
6
  </label>
@@ -8,7 +8,7 @@
8
8
  {{ description }}
9
9
  </div>
10
10
 
11
- <PopoverTrigger as-child :disabled="disabled || loading">
11
+ <PopoverTrigger as-child :disabled="blocked">
12
12
  <div :class="computedWrapperClasses" v-on="inputListeners" :id="id" ref="container"
13
13
  :aria-disabled="disabled ? 'true' : 'false'" :aria-labelledby="`${id}-label`" tabindex="0"
14
14
  data-m-select-trigger="true"
@@ -16,7 +16,7 @@
16
16
  <div :class="selectComputedClasses">
17
17
  <template v-if="multiple && Array.isArray(selected) && selected.length > 0">
18
18
  <MTag v-for="value in selectedMax" :key="value" :closeable="true"
19
- @close="event => removeSelected(event, value)" :size="size">
19
+ @close="() => removeSelected(value)" :size="size">
20
20
  {{ getSelectedLabel(value) }}
21
21
  </MTag>
22
22
  <template v-if="selectedCountNotShown > 0">
@@ -26,15 +26,16 @@
26
26
  </template>
27
27
  </template>
28
28
  <template v-else-if="!multiple && selected">
29
- {{ getSelectedLabel(selected) }}
29
+ <span :class="fillLabelClass">{{ getSelectedLabel(selected) }}</span>
30
30
  </template>
31
31
  <template v-else>
32
- <div :class="style.placeholder">{{ placeholder }}</div>
32
+ <div :class="[style.placeholder, fillLabelClass]">{{ placeholder }}</div>
33
33
  </template>
34
34
  <span :class="selectComputedArrowClasses">
35
35
  <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down"><path d="m6 9 6 6 6-6"/></svg>
36
36
  </span>
37
37
  </div>
38
+ <MClearButton v-if="showClear" :class="style.clear" @clear="clearSelected" />
38
39
  </div>
39
40
  </PopoverTrigger>
40
41
 
@@ -71,24 +72,31 @@
71
72
  <input v-if="searchable" type="text" ref="searchRef" v-model="search" placeholder="Search"
72
73
  :class="style.searchInput" :data-input="`select-menu-search-${id}`" />
73
74
  </div>
74
- <div :class="style.options">
75
- <div v-for="(option, index) in computedOptions" :key="index" ref="optionsRef" :class="{
76
- [style.option]: true,
77
- [style.optionSelected]: option.active,
78
- [style.optionFocused]: selectedIndex === index
79
- }" @click.prevent="option.onClick" @mouseenter="selectedIndex = index"
80
- :data-element="`select-menu-option-${id}-${option.value}`">
81
- <span :class="style.optionIcon">
82
- <svg v-if="option.active" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
83
- <path fill-rule="evenodd" 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" clip-rule="evenodd"></path>
84
- </svg>
85
- </span>
86
- <span :class="style.optionLabel">{{ option.label }}</span>
87
- </div>
88
- <template v-if="computedOptions.length === 0">
89
- <div :class="style.noResults">No results</div>
75
+ <div v-if="showRemoteLoading" :class="style.noResults">Loading...</div>
76
+ <div v-else-if="computedOptions.length === 0" :class="style.noResults">No results</div>
77
+ <VList
78
+ v-else
79
+ ref="virtualListRef"
80
+ :data="computedOptions"
81
+ :item-size="optionItemHeight"
82
+ :style="{ height: optionsHeight + 'px', padding: '4px' }"
83
+ >
84
+ <template #default="{ item, index }">
85
+ <div :class="[optionSizeClass, {
86
+ [style.option]: true,
87
+ [style.optionSelected]: item.active,
88
+ [style.optionFocused]: selectedIndex === index
89
+ }]" @click.prevent="item.onClick" @mouseenter="selectedIndex = index"
90
+ :data-element="`select-menu-option-${id}-${item.value}`">
91
+ <span :class="style.optionIcon">
92
+ <svg v-if="item.active" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
93
+ <path fill-rule="evenodd" 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" clip-rule="evenodd"></path>
94
+ </svg>
95
+ </span>
96
+ <span :class="style.optionLabel">{{ item.label }}</span>
97
+ </div>
90
98
  </template>
91
- </div>
99
+ </VList>
92
100
  </PopoverContent>
93
101
  </Transition>
94
102
  </PopoverPortal>
@@ -101,7 +109,7 @@
101
109
  </template>
102
110
 
103
111
  <script lang="ts">
104
- import { useInput } from '../../composables/useInput';
112
+ import { useInput, type InputEmitsMap } from '../../composables/useInput';
105
113
  import type { MaybeArray, Size } from '../../types/props';
106
114
  import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui';
107
115
  import { useEventListener } from '@vueuse/core';
@@ -109,13 +117,20 @@ import {
109
117
  computed,
110
118
  defineComponent,
111
119
  nextTick,
120
+ onBeforeUnmount,
112
121
  ref,
113
122
  watch,
114
123
  watchEffect,
115
124
  type PropType
116
125
  } from 'vue';
117
126
  import MTag from '../tag/Tag.vue';
127
+ import MClearButton from '../clearbutton/ClearButton.vue';
128
+ import { VList } from 'virtua/vue';
118
129
  import * as style from './style';
130
+
131
+ interface VListHandle {
132
+ scrollToIndex(index: number, opts?: { align?: 'start' | 'center' | 'end' | 'nearest' }): void;
133
+ }
119
134
  import { fade } from '../../styles/transitions.css';
120
135
  import { useInteractive } from '../../composables/useInteractive';
121
136
  import type { OnUpdateValue, OnUpdateValueImpl } from './types';
@@ -127,7 +142,12 @@ let shouldOpenNextFocusedSelect = false;
127
142
  export default defineComponent({
128
143
  name: 'MSelect',
129
144
  emits: {
130
- ...useInput.emits()
145
+ ...(useInput.emits() as unknown as InputEmitsMap),
146
+ 'on-open': () => true,
147
+ 'on-close': () => true,
148
+ clear: () => true,
149
+ // Emitted when the search text changes (debounced when `remote`). Emits '' when the menu closes.
150
+ search: (_query: string) => true
131
151
  },
132
152
  props: {
133
153
  ...useInput.props(),
@@ -151,6 +171,17 @@ export default defineComponent({
151
171
  type: Boolean,
152
172
  default: false
153
173
  },
174
+ // Options are loaded from a server: search no longer filters locally,
175
+ // the consumer listens to `search` and updates `options`.
176
+ remote: {
177
+ type: Boolean,
178
+ default: false
179
+ },
180
+ // Debounce (ms) applied to the `search` event when `remote`
181
+ searchDebounce: {
182
+ type: Number,
183
+ default: 300
184
+ },
154
185
  size: {
155
186
  type: String as PropType<Size>,
156
187
  default: 'md'
@@ -184,7 +215,7 @@ export default defineComponent({
184
215
  default: false
185
216
  },
186
217
  error: {
187
- type: String
218
+ type: String as PropType<string | null>
188
219
  },
189
220
  allowBlank: {
190
221
  type: Boolean,
@@ -202,9 +233,26 @@ export default defineComponent({
202
233
  type: Boolean,
203
234
  default: false
204
235
  },
236
+ fill: {
237
+ type: Boolean,
238
+ default: false
239
+ },
240
+ noBorder: {
241
+ type: Boolean,
242
+ default: false
243
+ },
205
244
  zIndex: {
206
245
  type: Number
207
246
  },
247
+ clearable: {
248
+ type: Boolean,
249
+ default: false
250
+ },
251
+ // Value a single select resets to when cleared (e.g. a "Don't restrict" option)
252
+ clearValue: {
253
+ type: [String, Number, Boolean, Object] as PropType<string | number | boolean | object | null>,
254
+ default: undefined
255
+ },
208
256
  showDataOptions: {
209
257
  type: Boolean,
210
258
  default: true
@@ -214,6 +262,8 @@ export default defineComponent({
214
262
  },
215
263
  components: {
216
264
  MTag,
265
+ MClearButton,
266
+ VList,
217
267
  PopoverContent,
218
268
  PopoverPortal,
219
269
  PopoverRoot,
@@ -221,7 +271,7 @@ export default defineComponent({
221
271
  },
222
272
  setup(props, { emit, expose }) {
223
273
  const container = ref<HTMLElement | null>(null);
224
- const optionsRef = ref<HTMLElement[]>([]);
274
+ const virtualListRef = ref<VListHandle | null>(null);
225
275
  const searchRef = ref<HTMLInputElement>();
226
276
  const selectRef = ref<HTMLElement>();
227
277
  const popper = ref<HTMLElement>();
@@ -267,6 +317,23 @@ export default defineComponent({
267
317
 
268
318
  const disabled = computed(() => formDisabled || props.disabled);
269
319
  const loading = computed(() => formLoading || props.loading);
320
+ // In remote mode `loading` is shown inside the menu (so the user can keep
321
+ // typing while a search is in flight) rather than locking the trigger.
322
+ const blocked = computed(() => disabled.value || (loading.value && !props.remote));
323
+
324
+ // Remote options come and go between searches, so remember every option we've
325
+ // seen to keep labels for selected values that are no longer in `options`.
326
+ const knownOptions = new Map<any, any>();
327
+ watch(
328
+ () => props.options,
329
+ (options) => {
330
+ if (!props.remote || !options) {
331
+ return;
332
+ }
333
+ options.forEach(option => knownOptions.set(option[props.optionValue], option));
334
+ },
335
+ { immediate: true }
336
+ );
270
337
 
271
338
  const selected = computed<any | any[]>({
272
339
  get() {
@@ -331,80 +398,44 @@ export default defineComponent({
331
398
  };
332
399
  });
333
400
 
401
+ const showRemoteLoading = computed(() => props.remote && loading.value);
402
+
334
403
  const computedOptions = computed(() => {
335
404
  if (!props.options || props.options.length == 0) {
336
405
  return [];
337
406
  }
338
407
 
339
- // Filter options by search
340
- if (props.searchable && search.value) {
341
- let options = props.options
342
- .filter(option => {
343
- return option[props.optionLabel]
344
- .toLowerCase()
345
- .includes(search.value.toLowerCase());
346
- })
347
- .map(option => {
348
- if (option[props.optionValue] === undefined) {
349
- throw new Error('Option value is undefined');
350
- }
351
-
352
- if (option[props.optionLabel] === undefined) {
353
- throw new Error('Option label is undefined');
354
- }
355
-
356
- let value = option[props.optionValue];
357
- let active: boolean;
358
- if (props.multiple && Array.isArray(selected.value)) {
359
- active = selected.value.includes(value);
360
- } else {
361
- active = selected.value === value;
362
- }
363
-
364
- return {
365
- value: value,
366
- label: option[props.optionLabel],
367
- active: active,
368
- onClick: () => handleOptionClick(value)
369
- };
370
- });
408
+ // Remote options are already filtered by the server
409
+ const query = props.searchable && !props.remote
410
+ ? search.value.toLowerCase()
411
+ : '';
371
412
 
372
- if (props.allowBlank) {
373
- options.unshift({
374
- value: '',
375
- label: '',
376
- active: false,
377
- onClick: () => handleOptionClick('')
378
- });
379
- }
380
-
381
- return options;
382
- }
383
-
384
- let options = props.options.map(option => {
385
- if (option[props.optionValue] === undefined) {
386
- throw new Error('Option value is undefined');
387
- }
413
+ const options = props.options
414
+ .filter(option => !query || option[props.optionLabel].toLowerCase().includes(query))
415
+ .map(option => {
416
+ if (option[props.optionValue] === undefined) {
417
+ throw new Error('Option value is undefined');
418
+ }
388
419
 
389
- if (option[props.optionLabel] === undefined) {
390
- throw new Error('Option label is undefined');
391
- }
420
+ if (option[props.optionLabel] === undefined) {
421
+ throw new Error('Option label is undefined');
422
+ }
392
423
 
393
- let value = option[props.optionValue];
394
- let active: boolean;
395
- if (props.multiple && Array.isArray(selected.value)) {
396
- active = selected.value.includes(value);
397
- } else {
398
- active = selected.value === value;
399
- }
424
+ const value = option[props.optionValue];
425
+ let active: boolean;
426
+ if (props.multiple && Array.isArray(selected.value)) {
427
+ active = selected.value.includes(value);
428
+ } else {
429
+ active = selected.value === value;
430
+ }
400
431
 
401
- return {
402
- value: value,
403
- label: option[props.optionLabel],
404
- active: active,
405
- onClick: () => handleOptionClick(value)
406
- };
407
- });
432
+ return {
433
+ value: value,
434
+ label: option[props.optionLabel],
435
+ active: active,
436
+ onClick: () => handleOptionClick(value)
437
+ };
438
+ });
408
439
 
409
440
  if (props.allowBlank) {
410
441
  options.unshift({
@@ -441,7 +472,7 @@ export default defineComponent({
441
472
  }
442
473
 
443
474
  function openSelect(forceOpen = false) {
444
- if (disabled.value || loading.value) {
475
+ if (blocked.value) {
445
476
  return;
446
477
  }
447
478
 
@@ -593,9 +624,33 @@ export default defineComponent({
593
624
  }
594
625
  }
595
626
 
596
- function removeSelected(event: MouseEvent, value: string | number) {
597
- event.preventDefault();
598
- event.stopPropagation();
627
+ const hasValue = computed(() => {
628
+ if (props.multiple) {
629
+ return Array.isArray(selected.value) && selected.value.length > 0;
630
+ }
631
+
632
+ if (props.clearValue !== undefined && selected.value === props.clearValue) {
633
+ return false;
634
+ }
635
+
636
+ return !!selected.value;
637
+ });
638
+
639
+ const showClear = computed(() => {
640
+ return props.clearable
641
+ && hasValue.value
642
+ && !blocked.value;
643
+ });
644
+
645
+ function clearSelected() {
646
+ selected.value = props.multiple
647
+ ? []
648
+ : (props.clearValue !== undefined ? props.clearValue : '');
649
+ emit('clear');
650
+ container.value?.focus();
651
+ }
652
+
653
+ function removeSelected(value: string | number) {
599
654
  if (!props.multiple) {
600
655
  return;
601
656
  }
@@ -741,21 +796,37 @@ export default defineComponent({
741
796
  ]
742
797
  })
743
798
 
799
+ const optionItemHeightMap: Record<string, number> = { xs: 30, sm: 32, md: 38, lg: 42, xl: 46 };
800
+ const optionItemHeight = computed(() => optionItemHeightMap[props.size] ?? 38);
801
+ const optionsHeight = computed(() => {
802
+ const total = computedOptions.value.length * optionItemHeight.value + 8; // 8px = paddingTop+paddingBottom
803
+ return Math.min(total, 222);
804
+ });
805
+ const optionSizeClass = computed(() => style.optionSizes[props.size]);
806
+ const fillRootClass = computed(() => props.fill ? style.fillRoot : undefined);
807
+ const fillLabelClass = computed(() => props.fill ? style.fillLabel : undefined);
808
+
744
809
  const computedWrapperClasses = computed(() => {
745
- return [style.wrapper];
810
+ return [
811
+ style.wrapper,
812
+ { [style.fill]: props.fill }
813
+ ];
746
814
  });
747
815
 
748
816
  const selectComputedClasses = computed(() => {
749
817
  return [
750
818
  style.base,
819
+ style.sizes[props.size],
751
820
  {
821
+ [style.hasClear]: showClear.value,
752
822
  [style.selectedTags]:
753
823
  props.multiple &&
754
824
  Array.isArray(selected.value) &&
755
825
  selected.value.length > 0,
756
- [style.focused]: open.value || focused.value,
757
- // [`m-select--${props.size}`]: props.size,
758
- 'm-select--disabled': disabled.value || loading.value,
826
+ [style.focused]: !props.noBorder && (open.value || focused.value),
827
+ [style.fillBase]: props.fill,
828
+ [style.noBorder]: props.noBorder,
829
+ 'm-select--disabled': blocked.value,
759
830
  'm-select--focused': open.value || focused.value,
760
831
  'm-select--multiple': props.multiple,
761
832
  'm-select--error': formError.value
@@ -766,7 +837,7 @@ export default defineComponent({
766
837
  function getSelectedLabel(value: string | number | []) {
767
838
  const option = props.options.find(
768
839
  i => i[props.optionValue] === value
769
- );
840
+ ) ?? (props.remote ? knownOptions.get(value) : undefined);
770
841
  if (!option) {
771
842
  return null;
772
843
  }
@@ -784,21 +855,42 @@ export default defineComponent({
784
855
  if (!keyboardNavigation.value) {
785
856
  return;
786
857
  }
787
- if (typeof index !== 'undefined' && optionsRef.value) {
788
- optionsRef.value[index].scrollIntoView({
789
- block: 'nearest',
790
- inline: 'nearest'
791
- });
858
+ if (typeof index !== 'undefined') {
859
+ virtualListRef.value?.scrollToIndex(index, { align: 'nearest' });
860
+ }
861
+ });
862
+
863
+ // Emit search changes. Remote searches are debounced so we don't hit the
864
+ // server on every keystroke; clearing the query (e.g. on close) is immediate.
865
+ let searchTimer: ReturnType<typeof setTimeout> | undefined;
866
+ watch(search, (query: string) => {
867
+ clearTimeout(searchTimer);
868
+
869
+ if (props.remote && props.searchDebounce > 0 && query !== '') {
870
+ searchTimer = setTimeout(() => emit('search', query), props.searchDebounce);
871
+ return;
872
+ }
873
+
874
+ emit('search', query);
875
+ });
876
+ onBeforeUnmount(() => clearTimeout(searchTimer));
877
+
878
+ // Remote options replace the list under the cursor, keep the highlight in range
879
+ watch(computedOptions, (options) => {
880
+ if (selectedIndex.value !== undefined && selectedIndex.value >= options.length) {
881
+ selectedIndex.value = 0;
792
882
  }
793
883
  });
794
884
 
795
885
  watch(open, (value: boolean) => {
796
886
  nextTick(() => {
797
887
  if (!value) {
888
+ emit('on-close');
798
889
  search.value = '';
799
890
  }
800
891
 
801
892
  if (value) {
893
+ emit('on-open');
802
894
  zIndex.value = nextZIndex();
803
895
  }
804
896
 
@@ -816,7 +908,7 @@ export default defineComponent({
816
908
  });
817
909
  }
818
910
 
819
- if (value && optionsRef.value) {
911
+ if (value) {
820
912
  const selectedValue = Array.isArray(selected.value)
821
913
  ? selected.value[0]
822
914
  : selected.value;
@@ -824,9 +916,7 @@ export default defineComponent({
824
916
  i => i.value === selectedValue
825
917
  );
826
918
  if (index !== -1) {
827
- optionsRef.value[index].scrollIntoView({
828
- block: 'nearest'
829
- });
919
+ nextTick(() => virtualListRef.value?.scrollToIndex(index, { align: 'nearest' }));
830
920
  selectedIndex.value = index;
831
921
  } else if (computedOptions.value.length > 0) {
832
922
  selectedIndex.value = 0;
@@ -852,6 +942,7 @@ export default defineComponent({
852
942
  const isOpen = computed(() => {
853
943
  if (
854
944
  props.multiple &&
945
+ !props.remote &&
855
946
  computedOptions.value.length == 0 &&
856
947
  !search.value
857
948
  ) {
@@ -894,7 +985,7 @@ export default defineComponent({
894
985
 
895
986
  return {
896
987
  container,
897
- optionsRef,
988
+ virtualListRef,
898
989
  searchRef,
899
990
  selectRef,
900
991
  popper,
@@ -922,13 +1013,22 @@ export default defineComponent({
922
1013
  getSelectedLabel,
923
1014
  openSelect,
924
1015
  removeSelected,
1016
+ showClear,
1017
+ clearSelected,
925
1018
  inputError,
926
1019
  transitionName,
927
1020
  inputListeners,
928
1021
  style,
929
1022
  focused,
1023
+ blocked,
1024
+ showRemoteLoading,
930
1025
  fade,
931
- toggleAllItems
1026
+ toggleAllItems,
1027
+ optionItemHeight,
1028
+ optionsHeight,
1029
+ optionSizeClass,
1030
+ fillRootClass,
1031
+ fillLabelClass
932
1032
  };
933
1033
  }
934
1034
  });
@@ -0,0 +1,18 @@
1
+ <template>
2
+ <div style="display: flex; flex-direction: column; gap: 12px; max-width: 320px;">
3
+ <MSelect v-model="single" :options="options" clearable />
4
+ <MSelect v-model="multiple" :options="options" multiple clearable />
5
+ </div>
6
+ </template>
7
+
8
+ <script lang="ts" setup>
9
+ import { ref } from 'vue';
10
+
11
+ const options = Array.from({ length: 10 }, (_, i) => ({
12
+ value: i + 1,
13
+ label: `Option ${i + 1}`
14
+ }));
15
+
16
+ const single = ref(2);
17
+ const multiple = ref([1, 2]);
18
+ </script>
@@ -0,0 +1,52 @@
1
+ <template>
2
+ <div style="display: grid; grid-template-columns: 120px 1fr 1fr; border: 1px solid #e5e7eb;">
3
+ <div style="padding: 6px 8px; font-size: 13px; border-right: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb; background: #f9fafb; font-weight: 500;">Row 1</div>
4
+ <div style="height: 36px; border-right: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;">
5
+ <MSelect size="xs" v-model="row1Status" :options="statusOptions" fill no-border />
6
+ </div>
7
+ <div style="height: 36px; border-bottom: 1px solid #e5e7eb;">
8
+ <MSelect size="xs" v-model="row1Priority" :options="priorityOptions" fill no-border />
9
+ </div>
10
+
11
+ <div style="padding: 6px 8px; font-size: 13px; border-right: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb; background: #f9fafb; font-weight: 500;">Row 2</div>
12
+ <div style="height: 36px; border-right: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;">
13
+ <MSelect size="xs" v-model="row2Status" :options="statusOptions" fill no-border />
14
+ </div>
15
+ <div style="height: 36px; border-bottom: 1px solid #e5e7eb;">
16
+ <MSelect size="xs" v-model="row2Priority" :options="priorityOptions" fill no-border />
17
+ </div>
18
+
19
+ <div style="padding: 6px 8px; font-size: 13px; border-right: 1px solid #e5e7eb; background: #f9fafb; font-weight: 500;">Row 3</div>
20
+ <div style="height: 36px; border-right: 1px solid #e5e7eb;">
21
+ <MSelect size="xs" v-model="row3Status" :options="statusOptions" fill no-border />
22
+ </div>
23
+ <div style="height: 36px;">
24
+ <MSelect size="xs" v-model="row3Priority" :options="priorityOptions" fill no-border />
25
+ </div>
26
+ </div>
27
+ </template>
28
+
29
+ <script lang="ts" setup>
30
+ import { ref } from 'vue';
31
+
32
+ const statusOptions = [
33
+ { value: 'todo', label: 'To Do' },
34
+ { value: 'in_progress', label: 'In Progress' },
35
+ { value: 'in_review', label: 'In Review' },
36
+ { value: 'done', label: 'Done' }
37
+ ];
38
+
39
+ const priorityOptions = [
40
+ { value: 'low', label: 'Low' },
41
+ { value: 'medium', label: 'Medium' },
42
+ { value: 'high', label: 'High' },
43
+ { value: 'critical', label: 'Critical' }
44
+ ];
45
+
46
+ const row1Status = ref('in_progress');
47
+ const row1Priority = ref('high');
48
+ const row2Status = ref('todo');
49
+ const row2Priority = ref('medium');
50
+ const row3Status = ref('done');
51
+ const row3Priority = ref('low');
52
+ </script>