多维表格
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

411 lines
11 KiB

<script lang="ts" setup>
import { useVirtualList } from '@vueuse/core'
export type MultiSelectRawValueType = Array<string | number>
export type RawValueType = string | number | MultiSelectRawValueType
interface ListItem {
value?: RawValueType
label?: string
[key: string]: any
}
/**
* Props interface for the List component
*/
interface Props {
/** The currently selected value */
value: RawValueType
/** The list of items to display */
list: ListItem[]
/**
* The key to use for accessing the value from a list item
* @default 'value'
*/
optionValueKey?: string
/**
* The key to use for accessing the label from a list item
* @default 'label'
*/
optionLabelKey?: string
/** Whether the list is open or closed */
open?: boolean
/**
* Whether to close the list after an item is selected
* @default true
*/
closeOnSelect?: boolean
/** Placeholder text for the search input */
searchInputPlaceholder?: string
/** Whether to show the currently selected option */
showSelectedOption?: boolean
/**
* The height of each item in the list, used for virtual list rendering.
* @default 38
*/
itemHeight?: number
/** Custom filter function for list items */
filterOption?: (input: string, option: ListItem, index: Number) => boolean
/**
* Indicates whether the component allows multiple selections.
*/
isMultiSelect?: boolean
/**
* The minimum number of items required in the list to enable search functionality.
*/
minItemsForSearch?: number
}
interface Emits {
(e: 'update:value', value: RawValueType): void
(e: 'update:open', open: boolean): void
(e: 'change', option: ListItem): void
}
const props = withDefaults(defineProps<Props>(), {
open: false,
closeOnSelect: true,
searchInputPlaceholder: '',
showSelectedOption: true,
optionValueKey: 'value',
optionLabelKey: 'label',
itemHeight: 38,
isMultiSelect: false,
minItemsForSearch: 4,
})
const emits = defineEmits<Emits>()
const vModel = useVModel(props, 'value', emits, {
defaultValue: props.isMultiSelect ? ([] as MultiSelectRawValueType) : undefined,
})
const vOpen = useVModel(props, 'open', emits)
const { optionValueKey, optionLabelKey } = props
const { closeOnSelect, showSelectedOption } = toRefs(props)
const listRef = ref<HTMLDivElement>()
const searchQuery = ref('')
const inputRef = ref()
const activeOptionIndex = ref(-1)
const showHoverEffectOnSelectedOption = ref(true)
const isSearchEnabled = computed(() => props.list.length > props.minItemsForSearch)
const keyDown = ref(false)
/**
* Computed property that filters the list of options based on the search query.
* If a custom filter function is provided via props.filterOption, it will be used instead of the default filtering logic.
*
* @returns Filtered list of options
*
* @typeparam ListItem - The type of items in the list
*/
const list = computed(() => {
const query = searchQuery.value.toLowerCase()
return props.list.filter((item, i) => {
if (props?.filterOption) {
return props.filterOption(query, item, i)
} else {
return item[optionLabelKey]?.toLowerCase()?.includes(query)
}
})
})
const {
list: virtualList,
containerProps,
wrapperProps,
scrollTo,
} = useVirtualList(list, {
itemHeight: props.itemHeight,
})
/**
* Compares the given value with the current vModel value.
* If the component is in multi-select mode, it checks if the value is included in the vModel array.
* Otherwise, it performs a direct equality check.
*
* @param value - The value to compare with the vModel value.
* @returns {boolean} - True if the value matches the vModel value, false otherwise.
*/
function compareVModel(value: string | number): boolean {
if (props.isMultiSelect) {
return (vModel.value as MultiSelectRawValueType).includes(value)
}
return vModel.value === value
}
/**
* Resets the hover effect on the selected option
* @param clearActiveOption - Whether to clear the active option index
*/
const handleResetHoverEffect = (clearActiveOption = false, newActiveIndex?: number) => {
if ((clearActiveOption && keyDown.value) || (clearActiveOption && activeOptionIndex.value === newActiveIndex)) {
return
}
if (clearActiveOption && showHoverEffectOnSelectedOption.value) {
activeOptionIndex.value = -1
}
if (newActiveIndex !== undefined) {
activeOptionIndex.value = newActiveIndex
}
if (!showHoverEffectOnSelectedOption.value) return
showHoverEffectOnSelectedOption.value = false
}
/**
* Handles the selection of an option from the list
*
* @param option - The selected list item
*
* This function is responsible for handling the selection of an option from the list.
* It updates the model value, emits a change event, and optionally closes the dropdown.
*/
const handleSelectOption = (option: ListItem, index?: number) => {
if (!option?.[optionValueKey]) return
if (index !== undefined) {
activeOptionIndex.value = index
}
if (props.isMultiSelect) {
if ((vModel.value as MultiSelectRawValueType).includes(option?.[optionValueKey])) {
vModel.value = (vModel.value as MultiSelectRawValueType).filter((op) => op !== option?.[optionValueKey])
} else {
vModel.value = [...(vModel.value as MultiSelectRawValueType), option?.[optionValueKey]]
}
} else {
vModel.value = option[optionValueKey] as RawValueType
}
emits('change', option)
if (closeOnSelect.value) {
vOpen.value = false
}
}
/**
* Automatically scrolls to the active option in the list
*/
const handleAutoScrollOption = (useDelay = false) => {
if (activeOptionIndex.value === -1) return
if (!useDelay) {
scrollTo(activeOptionIndex.value)
return
}
setTimeout(() => {
scrollTo(activeOptionIndex.value)
}, 150)
}
const onArrowDown = () => {
keyDown.value = true
handleResetHoverEffect()
if (activeOptionIndex.value === list.value.length - 1) return
activeOptionIndex.value = Math.min(activeOptionIndex.value + 1, list.value.length - 1)
handleAutoScrollOption()
ncDelay(100).then(() => {
keyDown.value = false
})
}
const onArrowUp = () => {
keyDown.value = true
handleResetHoverEffect()
if (activeOptionIndex.value === 0) return
activeOptionIndex.value = Math.max(activeOptionIndex.value - 1, 0)
handleAutoScrollOption()
ncDelay(100).then(() => {
keyDown.value = false
})
}
const handleKeydownEnter = () => {
if (list.value[activeOptionIndex.value]) {
handleSelectOption(list.value[activeOptionIndex.value])
} else if (list.value[0]) {
handleSelectOption(list.value[activeOptionIndex.value])
}
}
/**
* Focuses the input box when the list is opened
*/
const focusInputBox = () => {
if (!vOpen.value) return
setTimeout(() => {
inputRef.value?.focus()
}, 100)
}
/**
* Focuses the list wrapper when the list is opened
*
* This function is called when the list is opened and search is not enabled.
* It sets a timeout to focus the list wrapper element after a short delay.
* This allows for proper rendering and improves accessibility.
*/
const focusListWrapper = () => {
if (!vOpen.value || isSearchEnabled.value) return
setTimeout(() => {
listRef.value?.focus()
}, 100)
}
watch(
vOpen,
() => {
if (!vOpen.value) return
searchQuery.value = ''
if (props.isMultiSelect) {
showHoverEffectOnSelectedOption.value = false
} else {
showHoverEffectOnSelectedOption.value = true
}
if (vModel.value && !props.isMultiSelect) {
activeOptionIndex.value = list.value.findIndex((o) => compareVModel(o?.[optionValueKey]))
nextTick(() => {
handleAutoScrollOption(true)
})
} else {
activeOptionIndex.value = -1
}
if (isSearchEnabled.value) {
focusInputBox()
} else {
focusListWrapper()
}
},
{
immediate: true,
},
)
</script>
<template>
<div
ref="listRef"
tabindex="0"
class="flex flex-col pt-2 w-64 !focus:(shadow-none outline-none)"
@keydown.arrow-down.prevent="onArrowDown"
@keydown.arrow-up.prevent="onArrowUp"
@keydown.enter.prevent="handleSelectOption(list[activeOptionIndex])"
>
<template v-if="isSearchEnabled">
<div class="w-full px-2" @click.stop>
<a-input
ref="inputRef"
v-model:value="searchQuery"
:placeholder="searchInputPlaceholder"
class="nc-toolbar-dropdown-search-field-input !pl-2 !pr-1.5"
allow-clear
:bordered="false"
@keydown.enter.stop="handleKeydownEnter"
@change="handleResetHoverEffect(false, 0)"
>
<template #prefix> <GeneralIcon icon="search" class="nc-search-icon h-3.5 w-3.5 mr-1" /> </template
></a-input>
</div>
<NcDivider />
</template>
<slot name="listHeader"></slot>
<div class="nc-list-wrapper">
<template v-if="list.length">
<div class="h-auto !max-h-[247px]">
<div
v-bind="containerProps"
class="nc-list !h-auto w-full nc-scrollbar-thin px-2 pb-2"
:style="{
maxHeight: '247px !important',
}"
>
<div v-bind="wrapperProps">
<div
v-for="{ data: option, index: idx } in virtualList"
:key="idx"
class="flex items-center gap-2 w-full py-2 px-2 hover:bg-gray-100 cursor-pointer rounded-md"
:class="[
`nc-list-option-${idx}`,
{
'nc-list-option-selected': compareVModel(option[optionValueKey]),
'bg-gray-100 ': showHoverEffectOnSelectedOption && compareVModel(option[optionValueKey]),
'bg-gray-100 nc-list-option-active': activeOptionIndex === idx,
},
]"
@mouseover="handleResetHoverEffect(true, idx)"
@click="handleSelectOption(option, idx)"
>
<slot name="listItem" :option="option" :is-selected="() => compareVModel(option[optionValueKey])" :index="idx">
<NcTooltip class="truncate flex-1" show-on-truncate-only>
<template #title>
{{ option[optionLabelKey] }}
</template>
{{ option[optionLabelKey] }}
</NcTooltip>
<GeneralIcon
v-if="showSelectedOption && compareVModel(option[optionValueKey])"
id="nc-selected-item-icon"
icon="check"
class="flex-none text-primary w-4 h-4"
/>
</slot>
</div>
</div>
</div>
</div>
</template>
<template v-if="!list.length">
<slot name="emptyState">
<div class="h-full text-center flex items-center justify-center gap-3 mt-4">
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" class="!my-0" />
</div>
</slot>
</template>
</div>
<slot name="listFooter"></slot>
</div>
</template>
<style lang="scss" scoped>
:deep(.nc-toolbar-dropdown-search-field-input) {
&.ant-input-affix-wrapper-focused {
.ant-input-prefix svg {
@apply text-brand-500;
}
}
.ant-input {
@apply placeholder-gray-500;
}
}
</style>