@saasmakers/ui 2.0.83 → 2.1.2

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,18 +1,19 @@
1
1
  <script lang="ts" setup>
2
2
  import type { FieldInput } from '#components'
3
+ import { autoUpdate, flip, size as floatingSize, offset, shift, useFloating } from '@floating-ui/vue'
3
4
  import { vOnClickOutside } from '@vueuse/components'
4
- import type { FieldSelect, FieldSelectOption } from '../../types/fields'
5
+ import { onKeyStroke } from '@vueuse/core'
6
+ import type { FieldSelect, FieldSelectMaxHeight, FieldSelectOption, FieldSize } from '../../types/fields'
5
7
 
6
8
  const props = withDefaults(defineProps<Omit<FieldSelect, 'modelValue'>>(), {
7
9
  border: 'full',
8
10
  caret: true,
9
11
  columns: () => [],
10
12
  description: '',
11
- direction: 'bottom',
13
+ direction: 'auto',
12
14
  disabled: false,
13
- hideCaret: false,
14
15
  hideError: false,
15
- hideLabel: false,
16
+ iconOnly: false,
16
17
  label: '',
17
18
  labelIcon: undefined,
18
19
  maxHeight: 'xs',
@@ -25,64 +26,81 @@ const props = withDefaults(defineProps<Omit<FieldSelect, 'modelValue'>>(), {
25
26
  searchPlaceholder: '',
26
27
  size: 'base',
27
28
  square: false,
29
+ uppercase: true,
28
30
  validation: undefined,
29
31
  })
30
32
 
31
33
  const emit = defineEmits<{
32
- change: [event: MouseEvent, value: string]
33
- click: [event: MouseEvent, value: string]
34
- optionClick: [event: MouseEvent, value: string]
34
+ change: [event: Event, value: string]
35
35
  }>()
36
36
 
37
+ const menuLayout = {
38
+ gap: 4,
39
+ maxHeight: {
40
+ lg: 512,
41
+ md: 448,
42
+ sm: 384,
43
+ xs: 320,
44
+ } satisfies Record<FieldSelectMaxHeight, number>,
45
+ viewportPadding: 8,
46
+ }
47
+
48
+ const textSizeByFieldSize = {
49
+ base: 'sm',
50
+ lg: 'base',
51
+ sm: 'xs',
52
+ } satisfies Record<FieldSize, BaseSize>
53
+
37
54
  const { t } = useI18n()
38
55
  const id = useId()
56
+ const labelId = `${id}-label`
57
+ const menuId = `${id}-options`
39
58
  const modelValue = defineModel<FieldSelect['modelValue']>({ default: '' })
40
- const opened = ref(false)
41
- const searchRaw = ref('')
42
- const searchInput = ref<InstanceType<typeof FieldInput>>()
59
+ const isOpen = ref(false)
60
+ const searchQuery = ref('')
61
+ const searchInputRef = ref<InstanceType<typeof FieldInput>>()
62
+ const labelRef = ref<HTMLElement>()
63
+ const triggerRef = ref<HTMLElement>()
64
+ const menuRef = ref<HTMLElement>()
43
65
 
44
- const computedColumns = computed(() => {
66
+ let hoverCloseTimeout: ReturnType<typeof setTimeout> | undefined
67
+ let isClosing = false
68
+
69
+ const resolvedColumns = computed(() => {
45
70
  if (props.columns.length > 0) {
46
71
  return props.columns
47
72
  }
48
73
 
49
- return [{ options: computedOptions.value }]
74
+ return [{ options: props.options }]
50
75
  })
51
76
 
52
- const computedOptions = computed(() => {
53
- const options: FieldSelectOption[] = []
54
-
77
+ const allOptions = computed(() => {
55
78
  if (props.columns.length > 0) {
56
- for (const column of props.columns) {
57
- for (const columnOption of column.options) {
58
- options.push(columnOption)
59
- }
60
- }
61
- }
62
- else {
63
- for (const columnOption of props.options) {
64
- options.push(columnOption)
65
- }
79
+ return props.columns.flatMap(column => column.options)
66
80
  }
67
81
 
68
- return options
82
+ return props.options
83
+ })
84
+
85
+ const isMultiColumn = computed(() => {
86
+ return resolvedColumns.value.length >= 2
69
87
  })
70
88
 
71
- const searchQueryCleaned = computed(() => {
72
- return normalizeText(searchRaw.value.trim())
89
+ const normalizedSearchQuery = computed(() => {
90
+ return normalizeText(searchQuery.value.trim())
73
91
  })
74
92
 
75
93
  const filteredColumns = computed(() => {
76
- if (!props.searchable || !searchQueryCleaned.value) {
77
- return computedColumns.value
94
+ if (!props.searchable || !normalizedSearchQuery.value) {
95
+ return resolvedColumns.value
78
96
  }
79
97
 
80
- return computedColumns.value
98
+ return resolvedColumns.value
81
99
  .map(column => ({
82
100
  ...column,
83
101
  options: column.options.filter((option) => {
84
- return normalizeText(option.text).includes(searchQueryCleaned.value)
85
- || normalizeText(option.value).includes(searchQueryCleaned.value)
102
+ return normalizeText(option.text).includes(normalizedSearchQuery.value)
103
+ || normalizeText(option.value).includes(normalizedSearchQuery.value)
86
104
  }),
87
105
  }))
88
106
  .filter(column => column.options.length > 0)
@@ -95,252 +113,346 @@ const firstFilteredOption = computed(() => {
95
113
  })
96
114
 
97
115
  const selectedOption = computed(() => {
98
- return computedOptions.value.find((option) => {
116
+ return allOptions.value.find((option) => {
99
117
  return option.value === modelValue.value
100
118
  })
101
119
  })
102
120
 
103
- const textSize = computed<BaseSize>(() => {
104
- switch (props.size) {
105
- case 'base':
106
- return 'sm'
107
- case 'lg':
108
- return 'base'
109
- case 'sm':
110
- return 'xs'
111
- default:
112
- return 'sm'
113
- }
121
+ const textSize = computed(() => {
122
+ return textSizeByFieldSize[props.size]
123
+ })
124
+
125
+ const placement = computed(() => {
126
+ return props.direction === 'top' ? 'top-start' : 'bottom-start'
127
+ })
128
+
129
+ const middleware = computed(() => {
130
+ return [
131
+ offset(menuLayout.gap),
132
+ ...(props.direction === 'auto' ? [flip()] : []),
133
+ shift({ padding: menuLayout.viewportPadding }),
134
+ floatingSize({
135
+ apply({ availableHeight, availableWidth, elements, rects }) {
136
+ const preferredMaxHeight = menuLayout.maxHeight[props.maxHeight]
137
+ const maxHeight = Math.max(0, Math.min(preferredMaxHeight, availableHeight))
138
+
139
+ elements.floating.style.setProperty('--field-select-menu-max-height', `${maxHeight}px`)
140
+
141
+ Object.assign(elements.floating.style, {
142
+ maxWidth: `${availableWidth}px`,
143
+ minWidth: `${rects.reference.width}px`,
144
+ })
145
+ },
146
+ padding: menuLayout.viewportPadding,
147
+ }),
148
+ ]
149
+ })
150
+
151
+ const { floatingStyles, isPositioned } = useFloating(triggerRef, menuRef, {
152
+ middleware,
153
+ placement,
154
+ strategy: 'fixed',
155
+ whileElementsMounted: autoUpdate,
114
156
  })
115
157
 
116
- watch(opened, async (isOpened) => {
117
- if (isOpened && props.searchable) {
118
- await nextTick()
158
+ onKeyStroke('Escape', (event) => {
159
+ if (!isOpen.value) {
160
+ return
161
+ }
162
+
163
+ event.preventDefault()
164
+ close({ restoreFocus: true })
165
+ }, { target: window })
119
166
 
120
- searchInput.value?.focus()
167
+ watch(isOpen, async (opened) => {
168
+ if (!opened || !props.searchable) {
169
+ return
121
170
  }
171
+
172
+ await nextTick()
173
+
174
+ searchInputRef.value?.focus()
122
175
  })
123
176
 
124
- function onClose() {
125
- reset()
126
- }
177
+ watch(() => props.disabled, (isDisabled) => {
178
+ if (isDisabled) {
179
+ close()
180
+ }
181
+ })
127
182
 
128
- function onContainerClick(event: MouseEvent) {
129
- if (!props.disabled) {
130
- opened.value = !opened.value
183
+ onBeforeUnmount(() => {
184
+ clearHoverCloseTimeout()
185
+ })
131
186
 
132
- if (selectedOption.value) {
133
- emit('click', event, selectedOption.value.value)
134
- }
187
+ function clearHoverCloseTimeout() {
188
+ if (hoverCloseTimeout !== undefined) {
189
+ clearTimeout(hoverCloseTimeout)
190
+
191
+ hoverCloseTimeout = undefined
135
192
  }
136
193
  }
137
194
 
138
- function onContainerKeypress(event: KeyboardEvent) {
139
- if (['Enter', 'Space'].includes(event.code) && !props.disabled) {
140
- (event.target as HTMLElement)?.click()
195
+ function close(options?: {
196
+ restoreFocus?: boolean
197
+ }) {
198
+ clearHoverCloseTimeout()
199
+
200
+ isClosing = true
201
+ isOpen.value = false
202
+ searchQuery.value = ''
203
+
204
+ if (options?.restoreFocus) {
205
+ triggerRef.value?.focus()
141
206
  }
207
+
208
+ isClosing = false
142
209
  }
143
210
 
144
- function onLabelClick() {
145
- if (!props.disabled) {
146
- opened.value = !opened.value
211
+ function isEventTargetInside(event: FocusEvent | MouseEvent) {
212
+ const nextTarget = event.relatedTarget
213
+
214
+ if (!(nextTarget instanceof Node)) {
215
+ return false
147
216
  }
217
+
218
+ return menuRef.value?.contains(nextTarget) === true || triggerRef.value?.contains(nextTarget) === true
148
219
  }
149
220
 
150
- function onMouseEnterOrFocus() {
151
- if (props.openOnHover && !props.disabled) {
152
- opened.value = true
221
+ function isSelected(option: FieldSelectOption) {
222
+ return option.value === selectedOption.value?.value
223
+ }
224
+
225
+ function onHoverEnd(event: FocusEvent | MouseEvent) {
226
+ if (isClosing || !props.openOnHover || props.disabled || isEventTargetInside(event)) {
227
+ return
153
228
  }
229
+
230
+ clearHoverCloseTimeout()
231
+
232
+ hoverCloseTimeout = setTimeout(() => {
233
+ isOpen.value = false
234
+ hoverCloseTimeout = undefined
235
+ }, 100)
154
236
  }
155
237
 
156
- function onMouseLeaveOrBlur() {
157
- if (props.openOnHover && !props.disabled) {
158
- opened.value = false
238
+ function onHoverStart() {
239
+ if (isClosing || !props.openOnHover || props.disabled) {
240
+ return
159
241
  }
242
+
243
+ clearHoverCloseTimeout()
244
+
245
+ isOpen.value = true
160
246
  }
161
247
 
162
- function onOptionClick(event: MouseEvent, option: FieldSelectOption) {
163
- if (!props.disabled) {
164
- if (selectedOption.value?.value === option.value) {
165
- reset()
166
- }
167
- else {
168
- selectOption(event, option.value)
169
- }
248
+ function onLabelClick() {
249
+ toggle()
170
250
 
171
- emit('optionClick', event, option.value)
251
+ if (isOpen.value) {
252
+ triggerRef.value?.focus()
172
253
  }
173
254
  }
174
255
 
175
- function onOptionKeyDown(event: KeyboardEvent) {
176
- if (!props.disabled) {
177
- (event.currentTarget as HTMLElement).click()
256
+ function onOptionClick(event: Event, option: FieldSelectOption) {
257
+ const restoreFocus = event instanceof KeyboardEvent
258
+
259
+ if (isSelected(option)) {
260
+ close({ restoreFocus })
261
+
262
+ return
178
263
  }
264
+
265
+ selectOption(event, option.value, restoreFocus)
266
+ }
267
+
268
+ function onOptionKeydown(event: KeyboardEvent, option: FieldSelectOption) {
269
+ onOptionClick(event, option)
179
270
  }
180
271
 
181
272
  function onSearchKeydown(event: KeyboardEvent) {
182
- if (event.key === 'Escape') {
183
- reset()
184
- }
185
- else if (event.key === 'Enter' && !props.disabled && firstFilteredOption.value) {
186
- event.preventDefault()
187
- onOptionClick(event as unknown as MouseEvent, firstFilteredOption.value)
273
+ if (event.key !== 'Enter' || !firstFilteredOption.value) {
274
+ return
188
275
  }
276
+
277
+ event.preventDefault()
278
+ onOptionClick(event, firstFilteredOption.value)
279
+ }
280
+
281
+ function onTriggerClick() {
282
+ toggle()
189
283
  }
190
284
 
191
- function reset() {
192
- opened.value = false
193
- searchRaw.value = ''
285
+ function onTriggerKeydown() {
286
+ toggle()
194
287
  }
195
288
 
196
- function selectOption(event: MouseEvent, value: string) {
289
+ function selectOption(event: Event, value: string, restoreFocus = false) {
197
290
  modelValue.value = value
198
291
 
199
292
  emit('change', event, value)
200
- reset()
293
+ close({ restoreFocus })
294
+ }
295
+
296
+ function toggle() {
297
+ if (props.disabled) {
298
+ return
299
+ }
300
+
301
+ isOpen.value = props.openOnHover ? true : !isOpen.value
201
302
  }
202
303
  </script>
203
304
 
204
305
  <template>
205
306
  <div
206
307
  class="flex flex-col"
207
- :class="{ 'w-fit': hideLabel || square }"
308
+ :class="{ 'w-fit': iconOnly || square }"
208
309
  >
209
- <FieldLabel
310
+ <div
210
311
  v-if="label"
211
- :disabled="disabled"
212
- :for-field="id"
213
- has-margin-bottom
214
- :icon="labelIcon"
215
- :label="label"
216
- :required="required"
217
- :size="size"
218
- @click="onLabelClick"
219
- />
312
+ :id="labelId"
313
+ ref="labelRef"
314
+ >
315
+ <FieldLabel
316
+ :disabled="disabled"
317
+ :for-field="id"
318
+ has-margin-bottom
319
+ :icon="labelIcon"
320
+ :label="label"
321
+ :required="required"
322
+ :size="size"
323
+ @click="onLabelClick"
324
+ />
325
+ </div>
220
326
 
221
327
  <div
222
- v-on-click-outside="onClose"
328
+ v-on-click-outside="[() => close(), { ignore: [menuRef, labelRef] }]"
223
329
  class="text-left"
224
330
  :class="{
225
331
  'field-disabled': disabled,
226
332
  'cursor-pointer': !disabled,
227
- 'w-fit': hideLabel || square,
333
+ 'shadow-sm': border === 'full',
334
+ 'w-fit': iconOnly || square,
335
+ 'field-uppercase': uppercase,
228
336
  }"
229
337
  >
230
338
  <div
231
- class="relative"
339
+ :id="id"
340
+ ref="triggerRef"
341
+ class="group flex items-center rounded-lg bg-white outline-none dark:bg-gray-900"
232
342
  :class="{
233
- 'shadow-sm': border === 'full',
234
- 'w-fit': hideLabel || square,
343
+ 'base-box': border === 'full',
344
+ 'base-border-b': border === 'bottom',
345
+ 'border-0': border === 'none',
346
+ 'base-border-active': isOpen,
347
+ 'base-border-hover': !isOpen && !disabled,
348
+ 'base-border-interactive-focus-visible': !disabled,
349
+ 'text-xs h-sm': size === 'sm' && !square,
350
+ 'text-sm h-base': size === 'base' && !square,
351
+ 'text-base h-lg': size === 'lg' && !square,
352
+ 'text-xs size-sm': size === 'sm' && square,
353
+ 'text-sm size-base': size === 'base' && square,
354
+ 'text-base size-lg': size === 'lg' && square,
355
+ 'px-sm': padding && !iconOnly && !square && size === 'sm',
356
+ 'px-base': padding && !iconOnly && !square && size === 'base',
357
+ 'px-lg': padding && !iconOnly && !square && size === 'lg',
358
+ 'px-2': padding && iconOnly && !square,
359
+ 'inline-flex w-fit gap-1': iconOnly && !square,
360
+ 'justify-center': square,
235
361
  }"
362
+ role="button"
363
+ :aria-controls="isOpen ? menuId : undefined"
364
+ :aria-disabled="disabled"
365
+ :aria-expanded="isOpen"
366
+ aria-haspopup="listbox"
367
+ :aria-labelledby="label ? labelId : undefined"
368
+ tabindex="0"
369
+ @click="onTriggerClick"
370
+ @keydown.enter.prevent="onTriggerKeydown"
371
+ @keydown.space.prevent="onTriggerKeydown"
372
+ @mouseenter="onHoverStart"
373
+ @mouseleave="onHoverEnd"
374
+ @focusin="onHoverStart"
375
+ @focusout="onHoverEnd"
236
376
  >
237
- <div
238
- class="group flex items-center rounded-lg bg-white outline-none dark:bg-gray-900"
239
- :class="{
240
- 'base-box': border === 'full',
241
- 'base-border-b': border === 'bottom',
242
- 'border-0': border === 'none',
243
- 'base-border-active': opened,
244
- 'base-border-hover': !opened && !disabled,
245
- 'base-border-interactive-focus-visible': !disabled,
246
- 'text-xs h-sm': size === 'sm' && !square,
247
- 'text-sm h-base': size === 'base' && !square,
248
- 'text-base h-lg': size === 'lg' && !square,
249
- 'text-xs size-sm': size === 'sm' && square,
250
- 'text-sm size-base': size === 'base' && square,
251
- 'text-base size-lg': size === 'lg' && square,
252
- 'px-sm': padding && !hideLabel && !square && size === 'sm',
253
- 'px-base': padding && !hideLabel && !square && size === 'base',
254
- 'px-lg': padding && !hideLabel && !square && size === 'lg',
255
- 'px-2': padding && hideLabel && !square,
256
- 'inline-flex w-fit gap-1': hideLabel && !square,
257
- 'justify-center': square,
258
- }"
259
- role="button"
260
- :aria-disabled="disabled"
261
- tabindex="0"
262
- @click="onContainerClick"
263
- @keypress.prevent="onContainerKeypress"
264
- @mouseenter="onMouseEnterOrFocus"
265
- @mouseleave="onMouseLeaveOrBlur"
266
- @focusin="onMouseEnterOrFocus"
267
- @focusout="onMouseLeaveOrBlur"
268
- >
269
- <template v-if="selectedOption">
270
- <BaseEmoji
271
- v-if="selectedOption.emoji"
272
- class="pointer-events-none shrink-0"
273
- :class="{ 'mr-2': !hideLabel && !square }"
274
- :clickable="false"
275
- :emoji="selectedOption.emoji"
276
- size="sm"
277
- />
278
-
279
- <BaseIcon
280
- v-else-if="selectedOption.icon"
281
- class="pointer-events-none"
282
- :class="{ 'mr-2': !hideLabel && !square }"
283
- :color="selectedOption.iconColor"
284
- :icon="selectedOption.icon"
285
- :size="textSize"
286
- />
287
-
288
- <span
289
- v-if="selectedOption.text && !hideLabel && !square"
290
- class="flex-1 truncate font-medium"
291
- >
292
- {{ selectedOption.text }}
293
- </span>
294
- </template>
377
+ <template v-if="selectedOption">
378
+ <BaseEmoji
379
+ v-if="selectedOption.emoji"
380
+ class="pointer-events-none shrink-0"
381
+ :class="{ 'mr-2': !iconOnly && !square }"
382
+ :clickable="false"
383
+ :emoji="selectedOption.emoji"
384
+ size="sm"
385
+ />
386
+
387
+ <BaseIcon
388
+ v-else-if="selectedOption.icon"
389
+ class="pointer-events-none"
390
+ :class="{ 'mr-2': !iconOnly && !square }"
391
+ :color="selectedOption.iconColor"
392
+ :icon="selectedOption.icon"
393
+ :size="textSize"
394
+ />
295
395
 
296
396
  <span
297
- v-else-if="placeholder"
298
- class="flex-1 truncate text-gray-600 font-medium dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-gray-100"
397
+ v-if="selectedOption.text && !iconOnly && !square"
398
+ class="flex-1 truncate font-medium"
299
399
  >
300
- {{ placeholder }}
400
+ {{ selectedOption.text }}
301
401
  </span>
402
+ </template>
302
403
 
303
- <BaseIcon
304
- v-if="caret && !hideCaret && !square"
305
- class="flex-initial"
306
- :class="{
307
- 'ml-2': !hideLabel,
308
- 'rotate-180': opened,
309
- }"
310
- color="gray"
311
- :icon="getLayerIcon('arrowDown')"
312
- />
313
- </div>
404
+ <span
405
+ v-else-if="placeholder"
406
+ class="flex-1 truncate text-gray-600 font-medium dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-gray-100"
407
+ >
408
+ {{ placeholder }}
409
+ </span>
410
+
411
+ <BaseIcon
412
+ v-if="caret && !square"
413
+ class="flex-initial"
414
+ :class="{
415
+ 'ml-2': !iconOnly,
416
+ 'rotate-180': isOpen,
417
+ }"
418
+ color="gray"
419
+ :icon="getLayerIcon('arrowDown')"
420
+ />
421
+ </div>
422
+ </div>
314
423
 
424
+ <Teleport to="body">
425
+ <div
426
+ v-if="isOpen"
427
+ ref="menuRef"
428
+ class="z-[70] cursor-default"
429
+ :style="{
430
+ ...floatingStyles,
431
+ visibility: isPositioned ? 'visible' : 'hidden',
432
+ }"
433
+ @mouseenter="onHoverStart"
434
+ @mouseleave="onHoverEnd"
435
+ @focusin="onHoverStart"
436
+ @focusout="onHoverEnd"
437
+ >
315
438
  <Motion
316
- v-if="opened"
317
439
  :animate="fadeIn.animate"
318
440
  as="div"
319
- class="absolute left-0 right-0 z-10 mt-0.5 cursor-default"
320
- :class="{
321
- 'top-0': direction === 'bottom',
322
- 'bottom-0': direction === 'top',
323
- }"
324
441
  :initial="fadeIn.initial"
325
442
  >
326
443
  <div
327
- class="options min-w-xs overflow-y-auto border base-border-active rounded-lg bg-white font-medium dark:bg-gray-900"
444
+ :id="menuId"
445
+ class="options min-w-xs overflow-y-auto border base-border-active rounded-lg bg-white text-gray-600 font-medium dark:bg-gray-900 dark:text-gray-400"
328
446
  :class="{
329
- 'text-xs text-gray-600 dark:text-gray-400': size === 'sm',
330
- 'text-sm text-gray-600 dark:text-gray-400': size === 'base',
331
- 'text-base text-gray-600 dark:text-gray-400': size === 'lg',
332
- 'md:min-w-xl p-1 pb-0': computedColumns.length >= 2,
333
- 'mt-8': size === 'sm' && !padding && direction === 'bottom',
334
- 'mt-10': ((size === 'sm' && padding) || (size === 'base' && !padding)) && direction === 'bottom',
335
- 'mt-12': size === 'base' && padding && direction === 'bottom',
336
- 'mb-8': size === 'sm' && !padding && direction === 'top',
337
- 'mb-10': ((size === 'sm' && padding) || (size === 'base' && !padding)) && direction === 'top',
338
- 'mb-12': size === 'base' && padding && direction === 'top',
339
- 'max-h-xs': maxHeight === 'xs',
340
- 'max-h-sm': maxHeight === 'sm',
341
- 'max-h-md': maxHeight === 'md',
342
- 'max-h-lg': maxHeight === 'lg',
447
+ 'text-xs': size === 'sm',
448
+ 'text-sm': size === 'base',
449
+ 'text-base': size === 'lg',
450
+ 'field-uppercase': uppercase,
451
+ 'md:min-w-xl p-1 pb-0': isMultiColumn,
343
452
  }"
453
+ role="listbox"
454
+ :aria-labelledby="label ? labelId : undefined"
455
+ :style="{ maxHeight: 'var(--field-select-menu-max-height)' }"
344
456
  >
345
457
  <div
346
458
  v-if="searchable"
@@ -348,8 +460,8 @@ function selectOption(event: MouseEvent, value: string) {
348
460
  @click.stop
349
461
  >
350
462
  <FieldInput
351
- ref="searchInput"
352
- v-model="searchRaw"
463
+ ref="searchInputRef"
464
+ v-model="searchQuery"
353
465
  :autocomplete="false"
354
466
  background="white"
355
467
  border="none"
@@ -365,7 +477,7 @@ function selectOption(event: MouseEvent, value: string) {
365
477
  </div>
366
478
 
367
479
  <div
368
- v-if="searchable && searchQueryCleaned && !firstFilteredOption"
480
+ v-if="searchable && normalizedSearchQuery && !firstFilteredOption"
369
481
  class="flex justify-center base-border-b p-3"
370
482
  >
371
483
  <BaseIcon
@@ -379,7 +491,7 @@ function selectOption(event: MouseEvent, value: string) {
379
491
 
380
492
  <div
381
493
  v-for="(column, columnIndex) in filteredColumns"
382
- :key="columnIndex"
494
+ :key="column.title ?? columnIndex"
383
495
  >
384
496
  <BaseIcon
385
497
  v-if="column.title"
@@ -392,20 +504,20 @@ function selectOption(event: MouseEvent, value: string) {
392
504
  :key="option.value"
393
505
  class="flex cursor-pointer items-center outline-none"
394
506
  :class="{
395
- 'base-border-b base-bg-hover last:border-b-0': computedColumns.length < 2,
396
- 'h-sm px-sm': size === 'sm' && computedColumns.length < 2,
397
- 'h-base px-base': size === 'base' && computedColumns.length < 2,
398
- 'h-lg px-lg': size === 'lg' && computedColumns.length < 2,
399
- 'base-bg-hover px-2 py-1 last:mb-2': computedColumns.length >= 2,
400
- 'font-medium underline': option.value === selectedOption?.value,
401
- 'text-gray-900 dark:text-gray-100': option.value === selectedOption?.value,
402
- 'bg-white dark:bg-gray-900': selectedOption && option.value !== selectedOption.value,
507
+ 'base-border-b base-bg-hover last:border-b-0': !isMultiColumn,
508
+ 'h-sm px-sm': size === 'sm' && !isMultiColumn,
509
+ 'h-base px-base': size === 'base' && !isMultiColumn,
510
+ 'h-lg px-lg': size === 'lg' && !isMultiColumn,
511
+ 'base-bg-hover px-2 py-1 last:mb-2': isMultiColumn,
512
+ 'underline': isSelected(option),
513
+ 'text-gray-900 dark:text-gray-100': isSelected(option),
403
514
  }"
404
- role="button"
515
+ role="option"
516
+ :aria-selected="isSelected(option)"
405
517
  tabindex="0"
406
518
  @click="onOptionClick($event, option)"
407
- @keydown.enter.prevent="onOptionKeyDown"
408
- @keydown.space.prevent="onOptionKeyDown"
519
+ @keydown.enter.prevent="onOptionKeydown($event, option)"
520
+ @keydown.space.prevent="onOptionKeydown($event, option)"
409
521
  >
410
522
  <template v-if="option.emoji">
411
523
  <BaseEmoji
@@ -433,7 +545,7 @@ function selectOption(event: MouseEvent, value: string) {
433
545
  </div>
434
546
  </Motion>
435
547
  </div>
436
- </div>
548
+ </Teleport>
437
549
 
438
550
  <FieldMessage
439
551
  :description="description"
@@ -164,9 +164,8 @@ export interface FieldSelect {
164
164
  description?: BaseTextText
165
165
  direction?: FieldSelectDirection
166
166
  disabled?: boolean
167
- hideCaret?: boolean
168
167
  hideError?: boolean
169
- hideLabel?: boolean
168
+ iconOnly?: boolean
170
169
  label?: BaseTextText
171
170
  labelIcon?: string
172
171
  maxHeight?: FieldSelectMaxHeight
@@ -180,6 +179,7 @@ export interface FieldSelect {
180
179
  searchPlaceholder?: string
181
180
  size?: FieldSize
182
181
  square?: boolean
182
+ uppercase?: boolean
183
183
  validation?: VuelidateValidation
184
184
  }
185
185
 
@@ -190,7 +190,7 @@ export interface FieldSelectColumn {
190
190
  title?: string
191
191
  }
192
192
 
193
- export type FieldSelectDirection = 'bottom' | 'top'
193
+ export type FieldSelectDirection = 'auto' | 'bottom' | 'top'
194
194
 
195
195
  export type FieldSelectMaxHeight = 'lg' | 'md' | 'sm' | 'xs'
196
196
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasmakers/ui",
3
- "version": "2.0.83",
3
+ "version": "2.1.2",
4
4
  "private": false,
5
5
  "description": "Reusable Nuxt UI components for SaaS Makers projects",
6
6
  "license": "MIT",
@@ -28,6 +28,7 @@
28
28
  "@capacitor/core": "8.4.0",
29
29
  "@capacitor/preferences": "8.0.1",
30
30
  "@capgo/capacitor-social-login": "8.3.22",
31
+ "@floating-ui/vue": "2.0.1",
31
32
  "@nuxt/icon": "2.2.3",
32
33
  "@nuxt/scripts": "0.13.2",
33
34
  "@nuxtjs/color-mode": "3.5.2",