mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-14 12:14:41 +01:00
feat(SelectMenu): implement virtualization
This commit is contained in:
@@ -115,6 +115,16 @@ export interface SelectMenuProps<T extends ArrayOrNested<SelectMenuItem> = Array
|
|||||||
* @defaultValue false
|
* @defaultValue false
|
||||||
*/
|
*/
|
||||||
ignoreFilter?: boolean
|
ignoreFilter?: boolean
|
||||||
|
/**
|
||||||
|
* Estimated size (in px) of each item for virtualization.
|
||||||
|
* @defaultValue 35
|
||||||
|
*/
|
||||||
|
estimateSize?: number
|
||||||
|
/**
|
||||||
|
* Number of items rendered outside the visible area for virtualization.
|
||||||
|
* @defaultValue 5
|
||||||
|
*/
|
||||||
|
overscan?: number
|
||||||
class?: any
|
class?: any
|
||||||
ui?: SelectMenu['slots']
|
ui?: SelectMenu['slots']
|
||||||
}
|
}
|
||||||
@@ -166,7 +176,7 @@ export interface SelectMenuSlots<
|
|||||||
|
|
||||||
<script setup lang="ts" generic="T extends ArrayOrNested<SelectMenuItem>, VK extends GetItemKeys<T> | undefined = undefined, M extends boolean = false">
|
<script setup lang="ts" generic="T extends ArrayOrNested<SelectMenuItem>, VK extends GetItemKeys<T> | undefined = undefined, M extends boolean = false">
|
||||||
import { computed, toRef, toRaw } from 'vue'
|
import { computed, toRef, toRaw } from 'vue'
|
||||||
import { ComboboxRoot, ComboboxArrow, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxPortal, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxLabel, ComboboxSeparator, ComboboxItem, ComboboxItemIndicator, FocusScope, useForwardPropsEmits, useFilter } from 'reka-ui'
|
import { ComboboxRoot, ComboboxArrow, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxPortal, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxLabel, ComboboxSeparator, ComboboxItem, ComboboxItemIndicator, ComboboxViewport, ComboboxVirtualizer, FocusScope, useForwardPropsEmits, useFilter } from 'reka-ui'
|
||||||
import { defu } from 'defu'
|
import { defu } from 'defu'
|
||||||
import { reactivePick, createReusableTemplate } from '@vueuse/core'
|
import { reactivePick, createReusableTemplate } from '@vueuse/core'
|
||||||
import { useAppConfig } from '#imports'
|
import { useAppConfig } from '#imports'
|
||||||
@@ -189,7 +199,9 @@ const props = withDefaults(defineProps<SelectMenuProps<T, VK, M>>(), {
|
|||||||
searchInput: true,
|
searchInput: true,
|
||||||
labelKey: 'label' as never,
|
labelKey: 'label' as never,
|
||||||
resetSearchTermOnBlur: true,
|
resetSearchTermOnBlur: true,
|
||||||
resetSearchTermOnSelect: true
|
resetSearchTermOnSelect: true,
|
||||||
|
estimateSize: 35,
|
||||||
|
overscan: 5
|
||||||
})
|
})
|
||||||
const emits = defineEmits<SelectMenuEmits<T, VK, M>>()
|
const emits = defineEmits<SelectMenuEmits<T, VK, M>>()
|
||||||
const slots = defineSlots<SelectMenuSlots<T, VK, M>>()
|
const slots = defineSlots<SelectMenuSlots<T, VK, M>>()
|
||||||
@@ -344,6 +356,12 @@ function onSelect(e: Event, item: SelectMenuItem) {
|
|||||||
function isSelectItem(item: SelectMenuItem): item is _SelectMenuItem {
|
function isSelectItem(item: SelectMenuItem): item is _SelectMenuItem {
|
||||||
return typeof item === 'object' && item !== null
|
return typeof item === 'object' && item !== null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getItemTextContent(item: SelectMenuItem): string {
|
||||||
|
if (typeof item === 'string') return item
|
||||||
|
if (typeof item !== 'object' || item === null) return String(item)
|
||||||
|
return get(item, props.labelKey as string) || String(item)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- eslint-disable vue/no-template-shadow -->
|
<!-- eslint-disable vue/no-template-shadow -->
|
||||||
@@ -418,58 +436,63 @@ function isSelectItem(item: SelectMenuItem): item is _SelectMenuItem {
|
|||||||
</slot>
|
</slot>
|
||||||
</ComboboxEmpty>
|
</ComboboxEmpty>
|
||||||
|
|
||||||
<div role="presentation" :class="ui.viewport({ class: props.ui?.viewport })">
|
<ComboboxViewport :class="ui.viewport({ class: props.ui?.viewport })">
|
||||||
<ReuseCreateItemTemplate v-if="createItem && createItemPosition === 'top'" />
|
<ReuseCreateItemTemplate v-if="createItem && createItemPosition === 'top'" />
|
||||||
|
|
||||||
<ComboboxGroup v-for="(group, groupIndex) in filteredGroups" :key="`group-${groupIndex}`" :class="ui.group({ class: props.ui?.group })">
|
<ComboboxVirtualizer
|
||||||
<template v-for="(item, index) in group" :key="`group-${groupIndex}-${index}`">
|
v-slot="{ option }"
|
||||||
<ComboboxLabel v-if="isSelectItem(item) && item.type === 'label'" :class="ui.label({ class: [props.ui?.label, item.ui?.label, item.class] })">
|
:options="filteredItems as AcceptableValue[]"
|
||||||
{{ get(item, props.labelKey as string) }}
|
:estimate-size="estimateSize"
|
||||||
</ComboboxLabel>
|
:overscan="overscan"
|
||||||
|
:text-content="getItemTextContent"
|
||||||
|
:class="ui.group({ class: props.ui?.group })"
|
||||||
|
>
|
||||||
|
<ComboboxLabel v-if="isSelectItem(option) && option.type === 'label'" :class="ui.label({ class: [props.ui?.label, option.ui?.label, option.class] })">
|
||||||
|
{{ get(option, props.labelKey as string) }}
|
||||||
|
</ComboboxLabel>
|
||||||
|
|
||||||
<ComboboxSeparator v-else-if="isSelectItem(item) && item.type === 'separator'" :class="ui.separator({ class: [props.ui?.separator, item.ui?.separator, item.class] })" />
|
<ComboboxSeparator v-else-if="isSelectItem(option) && option.type === 'separator'" :class="ui.separator({ class: [props.ui?.separator, option.ui?.separator, option.class] })" />
|
||||||
|
|
||||||
<ComboboxItem
|
<ComboboxItem
|
||||||
v-else
|
v-else
|
||||||
:class="ui.item({ class: [props.ui?.item, isSelectItem(item) && item.ui?.item, isSelectItem(item) && item.class] })"
|
:class="ui.item({ class: [props.ui?.item, isSelectItem(option) && option.ui?.item, isSelectItem(option) && option.class] })"
|
||||||
:disabled="isSelectItem(item) && item.disabled"
|
:disabled="isSelectItem(option) && option.disabled"
|
||||||
:value="props.valueKey && isSelectItem(item) ? get(item, props.valueKey as string) : item"
|
:value="props.valueKey && isSelectItem(option) ? get(option, props.valueKey as string) : option"
|
||||||
@select="onSelect($event, item)"
|
@select="onSelect($event, option)"
|
||||||
>
|
>
|
||||||
<slot name="item" :item="(item as NestedItem<T>)" :index="index">
|
<slot name="item" :item="(option as NestedItem<T>)" :index="0">
|
||||||
<slot name="item-leading" :item="(item as NestedItem<T>)" :index="index">
|
<slot name="item-leading" :item="(option as NestedItem<T>)" :index="0">
|
||||||
<UIcon v-if="isSelectItem(item) && item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: [props.ui?.itemLeadingIcon, item.ui?.itemLeadingIcon] })" />
|
<UIcon v-if="isSelectItem(option) && option.icon" :name="option.icon" :class="ui.itemLeadingIcon({ class: [props.ui?.itemLeadingIcon, option.ui?.itemLeadingIcon] })" />
|
||||||
<UAvatar v-else-if="isSelectItem(item) && item.avatar" :size="((item.ui?.itemLeadingAvatarSize || props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ class: [props.ui?.itemLeadingAvatar, item.ui?.itemLeadingAvatar] })" />
|
<UAvatar v-else-if="isSelectItem(option) && option.avatar" :size="((option.ui?.itemLeadingAvatarSize || props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="option.avatar" :class="ui.itemLeadingAvatar({ class: [props.ui?.itemLeadingAvatar, option.ui?.itemLeadingAvatar] })" />
|
||||||
<UChip
|
<UChip
|
||||||
v-else-if="isSelectItem(item) && item.chip"
|
v-else-if="isSelectItem(option) && option.chip"
|
||||||
:size="((props.ui?.itemLeadingChipSize || ui.itemLeadingChipSize()) as ChipProps['size'])"
|
:size="((props.ui?.itemLeadingChipSize || ui.itemLeadingChipSize()) as ChipProps['size'])"
|
||||||
inset
|
inset
|
||||||
standalone
|
standalone
|
||||||
v-bind="item.chip"
|
v-bind="option.chip"
|
||||||
:class="ui.itemLeadingChip({ class: [props.ui?.itemLeadingChip, item.ui?.itemLeadingChip] })"
|
:class="ui.itemLeadingChip({ class: [props.ui?.itemLeadingChip, option.ui?.itemLeadingChip] })"
|
||||||
/>
|
/>
|
||||||
</slot>
|
|
||||||
|
|
||||||
<span :class="ui.itemLabel({ class: [props.ui?.itemLabel, isSelectItem(item) && item.ui?.itemLabel] })">
|
|
||||||
<slot name="item-label" :item="(item as NestedItem<T>)" :index="index">
|
|
||||||
{{ isSelectItem(item) ? get(item, props.labelKey as string) : item }}
|
|
||||||
</slot>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span :class="ui.itemTrailing({ class: [props.ui?.itemTrailing, isSelectItem(item) && item.ui?.itemTrailing] })">
|
|
||||||
<slot name="item-trailing" :item="(item as NestedItem<T>)" :index="index" />
|
|
||||||
|
|
||||||
<ComboboxItemIndicator as-child>
|
|
||||||
<UIcon :name="selectedIcon || appConfig.ui.icons.check" :class="ui.itemTrailingIcon({ class: [props.ui?.itemTrailingIcon, isSelectItem(item) && item.ui?.itemTrailingIcon] })" />
|
|
||||||
</ComboboxItemIndicator>
|
|
||||||
</span>
|
|
||||||
</slot>
|
</slot>
|
||||||
</ComboboxItem>
|
|
||||||
</template>
|
<span :class="ui.itemLabel({ class: [props.ui?.itemLabel, isSelectItem(option) && option.ui?.itemLabel] })">
|
||||||
</ComboboxGroup>
|
<slot name="item-label" :item="(option as NestedItem<T>)" :index="0">
|
||||||
|
{{ isSelectItem(option) ? get(option, props.labelKey as string) : option }}
|
||||||
|
</slot>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span :class="ui.itemTrailing({ class: [props.ui?.itemTrailing, isSelectItem(option) && option.ui?.itemTrailing] })">
|
||||||
|
<slot name="item-trailing" :item="(option as NestedItem<T>)" :index="0" />
|
||||||
|
|
||||||
|
<ComboboxItemIndicator as-child>
|
||||||
|
<UIcon :name="selectedIcon || appConfig.ui.icons.check" :class="ui.itemTrailingIcon({ class: [props.ui?.itemTrailingIcon, isSelectItem(option) && option.ui?.itemTrailingIcon] })" />
|
||||||
|
</ComboboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
</slot>
|
||||||
|
</ComboboxItem>
|
||||||
|
</ComboboxVirtualizer>
|
||||||
|
|
||||||
<ReuseCreateItemTemplate v-if="createItem && createItemPosition === 'bottom'" />
|
<ReuseCreateItemTemplate v-if="createItem && createItemPosition === 'bottom'" />
|
||||||
</div>
|
</ComboboxViewport>
|
||||||
|
|
||||||
<slot name="content-bottom" />
|
<slot name="content-bottom" />
|
||||||
</FocusScope>
|
</FocusScope>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default (options: Required<ModuleOptions>) => {
|
|||||||
placeholder: 'truncate text-dimmed',
|
placeholder: 'truncate text-dimmed',
|
||||||
arrow: 'fill-default',
|
arrow: 'fill-default',
|
||||||
content: 'max-h-60 w-(--reka-select-trigger-width) bg-default shadow-lg rounded-md ring ring-default overflow-hidden data-[state=open]:animate-[scale-in_100ms_ease-out] data-[state=closed]:animate-[scale-out_100ms_ease-in] origin-(--reka-select-content-transform-origin) pointer-events-auto flex flex-col',
|
content: 'max-h-60 w-(--reka-select-trigger-width) bg-default shadow-lg rounded-md ring ring-default overflow-hidden data-[state=open]:animate-[scale-in_100ms_ease-out] data-[state=closed]:animate-[scale-out_100ms_ease-in] origin-(--reka-select-content-transform-origin) pointer-events-auto flex flex-col',
|
||||||
viewport: 'relative divide-y divide-default scroll-py-1 overflow-y-auto flex-1',
|
viewport: 'relative divide-y divide-default scroll-py-1 overflow-y-auto flex-1 max-h-60',
|
||||||
group: 'p-1 isolate',
|
group: 'p-1 isolate',
|
||||||
empty: 'py-2 text-center text-sm text-muted',
|
empty: 'py-2 text-center text-sm text-muted',
|
||||||
label: 'font-semibold text-highlighted',
|
label: 'font-semibold text-highlighted',
|
||||||
|
|||||||
Reference in New Issue
Block a user