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