340 lines
8.7 KiB
Vue
340 lines
8.7 KiB
Vue
<script lang="ts" setup>
|
||
import { computed, defineComponent, ref, watch } from 'vue'
|
||
import { CHANGE_EVENT, PREFIX, UPDATE_MODEL_EVENT } from '../_constants'
|
||
import { getMainClass } from '../_utils'
|
||
import { useTranslate } from '../../locale'
|
||
import { convertListToOptions } from '../cascader/helper'
|
||
import Tree from '../cascader/tree'
|
||
import type { CascaderOption, CascaderPane, CascaderTabs, CascaderValue, ConvertConfig } from '../cascader/types'
|
||
import NutIcon from '../icon/icon.vue'
|
||
import NutTabPane from '../tabpane/tabpane.vue'
|
||
import NutTabs from '../tabs/tabs.vue'
|
||
import { cascaderitemEmits, cascaderitemProps } from './cascaderitem'
|
||
|
||
const props = defineProps(cascaderitemProps)
|
||
|
||
const emit = defineEmits(cascaderitemEmits)
|
||
|
||
const classes = computed(() => {
|
||
return getMainClass(props, componentName)
|
||
})
|
||
|
||
const configs = computed(() => ({
|
||
lazy: props.lazy,
|
||
lazyLoad: props.lazyLoad,
|
||
valueKey: props.valueKey,
|
||
textKey: props.textKey,
|
||
childrenKey: props.childrenKey,
|
||
convertConfig: props.convertConfig,
|
||
}))
|
||
|
||
const tabsCursor = ref(0)
|
||
const initLoading = ref(false)
|
||
|
||
const innerValue = ref(props.modelValue as CascaderValue)
|
||
|
||
const tree = ref(new Tree([], {}))
|
||
|
||
const panes = ref<CascaderPane[]>([])
|
||
|
||
const isLazy = computed(() => configs.value.lazy && Boolean(configs.value.lazyLoad))
|
||
const lazyLoadMap = new Map()
|
||
|
||
let currentProcessNode: CascaderOption | null
|
||
|
||
async function init() {
|
||
lazyLoadMap.clear()
|
||
|
||
panes.value = []
|
||
tabsCursor.value = 0
|
||
initLoading.value = false
|
||
currentProcessNode = null
|
||
|
||
let { options } = props
|
||
|
||
if (configs.value.convertConfig) {
|
||
options = convertListToOptions(options as CascaderOption[], configs.value.convertConfig as ConvertConfig)
|
||
}
|
||
|
||
tree.value = new Tree(options as CascaderOption[], {
|
||
value: configs.value.valueKey,
|
||
text: configs.value.textKey,
|
||
children: configs.value.childrenKey,
|
||
})
|
||
|
||
if (isLazy.value && !tree.value.nodes.length) {
|
||
await invokeLazyLoad({
|
||
root: true,
|
||
loading: true,
|
||
text: '',
|
||
value: '',
|
||
})
|
||
}
|
||
|
||
panes.value = [{ nodes: tree.value.nodes, selectedNode: null }]
|
||
|
||
syncValue()
|
||
}
|
||
|
||
const methods = {
|
||
// 选中一个节点,静默模式不触发事件
|
||
async handleNode(node: CascaderOption, silent?: boolean) {
|
||
const { disabled, loading } = node
|
||
|
||
if ((!silent && disabled) || !panes.value[tabsCursor.value])
|
||
return
|
||
|
||
if (tree.value.isLeaf(node, isLazy.value)) {
|
||
node.leaf = true
|
||
panes.value[tabsCursor.value].selectedNode = node
|
||
panes.value = panes.value.slice(0, (node.level as number) + 1)
|
||
|
||
if (!silent) {
|
||
const pathNodes = panes.value.map(pane => pane.selectedNode)
|
||
|
||
emitChange(pathNodes as CascaderOption[])
|
||
emit('pathChange', pathNodes as CascaderOption[])
|
||
}
|
||
return
|
||
}
|
||
|
||
if (tree.value.hasChildren(node, isLazy.value)) {
|
||
const level = (node.level as number) + 1
|
||
|
||
panes.value[tabsCursor.value].selectedNode = node
|
||
panes.value = panes.value.slice(0, level)
|
||
panes.value.push({
|
||
nodes: node.children || [],
|
||
selectedNode: null,
|
||
})
|
||
|
||
tabsCursor.value = level
|
||
|
||
if (!silent) {
|
||
const pathNodes = panes.value.map(pane => pane.selectedNode)
|
||
emit('pathChange', pathNodes as CascaderOption[])
|
||
}
|
||
return
|
||
}
|
||
|
||
currentProcessNode = node
|
||
|
||
if (loading)
|
||
return
|
||
|
||
await invokeLazyLoad(node)
|
||
|
||
if (currentProcessNode === node) {
|
||
panes.value[tabsCursor.value].selectedNode = node
|
||
methods.handleNode(node, silent)
|
||
}
|
||
},
|
||
handleTabClick(tab: CascaderTabs) {
|
||
currentProcessNode = null
|
||
tabsCursor.value = Number(tab.paneKey)
|
||
},
|
||
|
||
isSelected(pane: CascaderPane, node: CascaderOption) {
|
||
return pane?.selectedNode?.value === node.value
|
||
},
|
||
}
|
||
|
||
async function syncValue() {
|
||
const currentValue = innerValue.value
|
||
|
||
if (currentValue === undefined || !tree.value.nodes.length) {
|
||
return
|
||
}
|
||
|
||
if (currentValue.length === 0) {
|
||
tabsCursor.value = 0
|
||
panes.value = [{ nodes: tree.value.nodes, selectedNode: null }]
|
||
return
|
||
}
|
||
|
||
let needToSync = currentValue
|
||
|
||
if (isLazy.value && Array.isArray(currentValue) && currentValue.length) {
|
||
needToSync = []
|
||
const parent = tree.value.nodes.find(node => node.value === currentValue[0])
|
||
|
||
if (parent) {
|
||
needToSync = [parent.value]
|
||
initLoading.value = true
|
||
|
||
const last = await currentValue.slice(1).reduce(async (p: Promise<CascaderOption | void>, value) => {
|
||
const parent = await p
|
||
|
||
await invokeLazyLoad(parent)
|
||
const node = parent?.children?.find(item => item.value === value)
|
||
|
||
if (node)
|
||
needToSync.push(value)
|
||
|
||
return Promise.resolve(node)
|
||
}, Promise.resolve(parent))
|
||
|
||
await invokeLazyLoad(last)
|
||
|
||
initLoading.value = false
|
||
}
|
||
}
|
||
|
||
if (needToSync.length && currentValue === props.modelValue) {
|
||
const pathNodes = tree.value.getPathNodesByValue(needToSync)
|
||
pathNodes.forEach((node, index) => {
|
||
tabsCursor.value = index
|
||
methods.handleNode(node, true)
|
||
})
|
||
}
|
||
}
|
||
|
||
async function invokeLazyLoad(node?: CascaderOption | void) {
|
||
if (!node)
|
||
return
|
||
|
||
if (!configs.value.lazyLoad) {
|
||
node.leaf = true
|
||
return
|
||
}
|
||
|
||
if (tree.value.isLeaf(node, isLazy.value) || tree.value.hasChildren(node, isLazy.value))
|
||
return
|
||
|
||
node.loading = true
|
||
|
||
const parent = node.root ? null : node
|
||
let lazyLoadPromise = lazyLoadMap.get(node)
|
||
|
||
if (!lazyLoadPromise) {
|
||
lazyLoadPromise = new Promise((resolve) => {
|
||
// 外部必须resolve
|
||
configs.value.lazyLoad?.(node, resolve)
|
||
})
|
||
lazyLoadMap.set(node, lazyLoadPromise)
|
||
}
|
||
|
||
const nodes: CascaderOption[] | void = await lazyLoadPromise
|
||
|
||
if (Array.isArray(nodes) && nodes.length > 0) {
|
||
tree.value.updateChildren(nodes, parent)
|
||
}
|
||
else {
|
||
// 如果加载完成后没有提供子节点,作为叶子节点处理
|
||
node.leaf = true
|
||
}
|
||
|
||
node.loading = false
|
||
lazyLoadMap.delete(node)
|
||
}
|
||
|
||
function emitChange(pathNodes: CascaderOption[]) {
|
||
const emitValue = pathNodes.map(node => node.value)
|
||
|
||
innerValue.value = emitValue
|
||
|
||
emit(UPDATE_MODEL_EVENT, emitValue)
|
||
emit(CHANGE_EVENT, emitValue, pathNodes)
|
||
}
|
||
|
||
function formatTabTitle(pane: CascaderPane) {
|
||
return pane.selectedNode ? pane.selectedNode.text : translate('select')
|
||
}
|
||
|
||
watch(() => [configs.value, props.options], () => {
|
||
init()
|
||
}, {
|
||
deep: true,
|
||
immediate: true,
|
||
})
|
||
|
||
watch(() => props.modelValue, (value) => {
|
||
if (value !== innerValue.value) {
|
||
innerValue.value = value as CascaderValue
|
||
|
||
syncValue()
|
||
}
|
||
})
|
||
|
||
watch(() => props.visible, (value) => {
|
||
// TODO: value为空时,保留上次选择记录,修复单元测试问题
|
||
if (value && Array.isArray(innerValue.value) && innerValue.value.length > 0) {
|
||
syncValue()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<script lang="ts">
|
||
const componentName = `${PREFIX}-cascader-item`
|
||
|
||
const { translate } = useTranslate(componentName)
|
||
|
||
export default defineComponent({
|
||
name: componentName,
|
||
options: {
|
||
virtualHost: true,
|
||
addGlobalClass: true,
|
||
styleIsolation: 'shared',
|
||
},
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<NutTabs
|
||
v-model="tabsCursor"
|
||
:custom-class="classes"
|
||
:custom-style="props.customStyle"
|
||
:type="props.titleType"
|
||
:size="props.titleSize"
|
||
:title-gutter="props.titleGutter"
|
||
:ellipsis="props.titleEllipsis"
|
||
title-scroll
|
||
@click="methods.handleTabClick"
|
||
>
|
||
<template v-if="!initLoading && panes.length">
|
||
<NutTabPane v-for="(pane, index) in panes" :key="index" :title="formatTabTitle(pane)">
|
||
<view class="nut-cascader-pane" role="menu">
|
||
<scroll-view style="height: 100%" :scroll-y="true">
|
||
<template v-for="node in pane.nodes" :key="node.value">
|
||
<view
|
||
class="nut-cascader-item__inner"
|
||
:class="{ active: methods.isSelected(pane, node), disabled: node.disabled }"
|
||
role="menuitemradio"
|
||
:aria-checked="methods.isSelected(pane, node)"
|
||
:aria-disabled="node.disabled || undefined"
|
||
@click="methods.handleNode(node, false)"
|
||
>
|
||
<view class="nut-cascader-item__title">
|
||
{{ node.text }}
|
||
</view>
|
||
|
||
<NutIcon
|
||
v-if="node.loading"
|
||
custom-class="nut-cascader-item__icon-loading"
|
||
loading
|
||
name="loading"
|
||
/>
|
||
<NutIcon
|
||
v-else
|
||
custom-class="nut-cascader-item__icon-check"
|
||
name="checklist"
|
||
/>
|
||
</view>
|
||
</template>
|
||
</scroll-view>
|
||
</view>
|
||
</NutTabPane>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<NutTabPane title="Loading...">
|
||
<view class="nut-cascader-pane" />
|
||
</NutTabPane>
|
||
</template>
|
||
</NutTabs>
|
||
</template>
|
||
|
||
<style lang="scss">
|
||
@import "./index";
|
||
</style>
|