vuecssuiant-designantdreactantantd-vueenterprisefrontendui-designvue-antdvue-antd-uivue3vuecomponent
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.
422 lines
13 KiB
422 lines
13 KiB
import classNames from '../_util/classNames'; |
|
import PropTypes from '../_util/vue-types'; |
|
import { isValidElement, splitAttrs, filterEmpty } from '../_util/props-util'; |
|
import DownOutlined from '@ant-design/icons-vue/DownOutlined'; |
|
import Checkbox from '../checkbox'; |
|
import Menu from '../menu'; |
|
import Dropdown from '../dropdown'; |
|
import Search from './search'; |
|
import ListBody from './ListBody'; |
|
import type { VNode, VNodeTypes, ExtractPropTypes, CSSProperties } from 'vue'; |
|
import { watchEffect, computed, defineComponent, ref } from 'vue'; |
|
import type { RadioChangeEvent } from '../radio/interface'; |
|
import type { TransferDirection, TransferItem } from './index'; |
|
import { stringType, arrayType, booleanType } from '../_util/type'; |
|
import { groupKeysMap } from '../_util/transKeys'; |
|
import type { CustomSlotsType } from '../_util/type'; |
|
|
|
const defaultRender = () => null; |
|
|
|
function isRenderResultPlainObject(result: VNode) { |
|
return !!( |
|
result && |
|
!isValidElement(result) && |
|
Object.prototype.toString.call(result) === '[object Object]' |
|
); |
|
} |
|
|
|
function getEnabledItemKeys<RecordType extends TransferItem>(items: RecordType[]) { |
|
return items.filter(data => !data.disabled).map(data => data.key); |
|
} |
|
|
|
export const transferListProps = { |
|
prefixCls: String, |
|
dataSource: arrayType<TransferItem[]>([]), |
|
filter: String, |
|
filterOption: Function, |
|
checkedKeys: PropTypes.arrayOf(PropTypes.string), |
|
handleFilter: Function, |
|
handleClear: Function, |
|
renderItem: Function, |
|
showSearch: booleanType(false), |
|
searchPlaceholder: String, |
|
notFoundContent: PropTypes.any, |
|
itemUnit: String, |
|
itemsUnit: String, |
|
renderList: PropTypes.any, |
|
disabled: booleanType(), |
|
direction: stringType<TransferDirection>(), |
|
showSelectAll: booleanType(), |
|
remove: String, |
|
selectAll: String, |
|
selectCurrent: String, |
|
selectInvert: String, |
|
removeAll: String, |
|
removeCurrent: String, |
|
selectAllLabel: PropTypes.any, |
|
showRemove: booleanType(), |
|
pagination: PropTypes.any, |
|
onItemSelect: Function, |
|
onItemSelectAll: Function, |
|
onItemRemove: Function, |
|
onScroll: Function, |
|
}; |
|
|
|
export type TransferListProps = Partial<ExtractPropTypes<typeof transferListProps>>; |
|
|
|
export default defineComponent({ |
|
compatConfig: { MODE: 3 }, |
|
name: 'TransferList', |
|
inheritAttrs: false, |
|
props: transferListProps, |
|
// emits: ['scroll', 'itemSelectAll', 'itemRemove', 'itemSelect'], |
|
slots: Object as CustomSlotsType<{ |
|
footer?: any; |
|
titleText?: any; |
|
default?: any; |
|
}>, |
|
setup(props, { attrs, slots }) { |
|
const filterValue = ref(''); |
|
const transferNode = ref(); |
|
const defaultListBodyRef = ref(); |
|
|
|
const renderListBody = (renderList: any, props: any) => { |
|
let bodyContent = renderList ? renderList(props) : null; |
|
const customize = !!bodyContent && filterEmpty(bodyContent).length > 0; |
|
if (!customize) { |
|
bodyContent = <ListBody {...props} ref={defaultListBodyRef} />; |
|
} |
|
return { |
|
customize, |
|
bodyContent, |
|
}; |
|
}; |
|
|
|
const renderItemHtml = (item: TransferItem) => { |
|
const { renderItem = defaultRender } = props; |
|
const renderResult = renderItem(item); |
|
const isRenderResultPlain = isRenderResultPlainObject(renderResult); |
|
return { |
|
renderedText: isRenderResultPlain ? renderResult.value : renderResult, |
|
renderedEl: isRenderResultPlain ? renderResult.label : renderResult, |
|
item, |
|
}; |
|
}; |
|
|
|
const filteredItems = ref([]); |
|
const filteredRenderItems = ref([]); |
|
|
|
watchEffect(() => { |
|
const fItems = []; |
|
const fRenderItems = []; |
|
|
|
props.dataSource.forEach(item => { |
|
const renderedItem = renderItemHtml(item); |
|
const { renderedText } = renderedItem; |
|
|
|
// Filter skip |
|
if (filterValue.value && filterValue.value.trim() && !matchFilter(renderedText, item)) { |
|
return null; |
|
} |
|
|
|
fItems.push(item); |
|
fRenderItems.push(renderedItem); |
|
}); |
|
filteredItems.value = fItems; |
|
filteredRenderItems.value = fRenderItems; |
|
}); |
|
|
|
const checkStatus = computed(() => { |
|
const { checkedKeys } = props; |
|
if (checkedKeys.length === 0) { |
|
return 'none'; |
|
} |
|
const checkedKeysMap = groupKeysMap(checkedKeys); |
|
if (filteredItems.value.every(item => checkedKeysMap.has(item.key) || !!item.disabled)) { |
|
return 'all'; |
|
} |
|
return 'part'; |
|
}); |
|
|
|
const enabledItemKeys = computed(() => { |
|
return getEnabledItemKeys(filteredItems.value); |
|
}); |
|
|
|
const getNewSelectKeys = (keys, unCheckedKeys) => { |
|
return Array.from(new Set([...keys, ...props.checkedKeys])).filter( |
|
key => unCheckedKeys.indexOf(key) === -1, |
|
); |
|
}; |
|
|
|
const getCheckBox = ({ disabled, prefixCls }: { disabled?: boolean; prefixCls?: string }) => { |
|
const checkedAll = checkStatus.value === 'all'; |
|
const checkAllCheckbox = ( |
|
<Checkbox |
|
disabled={props.dataSource?.length === 0 || disabled} |
|
checked={checkedAll} |
|
indeterminate={checkStatus.value === 'part'} |
|
class={`${prefixCls}-checkbox`} |
|
onChange={() => { |
|
// Only select enabled items |
|
|
|
const keys = enabledItemKeys.value; |
|
props.onItemSelectAll( |
|
getNewSelectKeys(!checkedAll ? keys : [], checkedAll ? props.checkedKeys : []), |
|
); |
|
}} |
|
/> |
|
); |
|
|
|
return checkAllCheckbox; |
|
}; |
|
|
|
const handleFilter = (e: RadioChangeEvent) => { |
|
const { |
|
target: { value: filter }, |
|
} = e; |
|
filterValue.value = filter; |
|
props.handleFilter?.(e); |
|
}; |
|
const handleClear = (e: Event) => { |
|
filterValue.value = ''; |
|
props.handleClear?.(e); |
|
}; |
|
const matchFilter = (text: string, item: TransferItem) => { |
|
const { filterOption } = props; |
|
if (filterOption) { |
|
return filterOption(filterValue.value, item); |
|
} |
|
return text.includes(filterValue.value); |
|
}; |
|
|
|
const getSelectAllLabel = (selectedCount: number, totalCount: number) => { |
|
const { itemsUnit, itemUnit, selectAllLabel } = props; |
|
if (selectAllLabel) { |
|
return typeof selectAllLabel === 'function' |
|
? selectAllLabel({ selectedCount, totalCount }) |
|
: selectAllLabel; |
|
} |
|
const unit = totalCount > 1 ? itemsUnit : itemUnit; |
|
return ( |
|
<> |
|
{(selectedCount > 0 ? `${selectedCount}/` : '') + totalCount} {unit} |
|
</> |
|
); |
|
}; |
|
|
|
const notFoundContentEle = computed(() => |
|
Array.isArray(props.notFoundContent) |
|
? props.notFoundContent[props.direction === 'left' ? 0 : 1] |
|
: props.notFoundContent, |
|
); |
|
const getListBody = ( |
|
prefixCls: string, |
|
searchPlaceholder: string, |
|
checkedKeys: string[], |
|
renderList: Function, |
|
showSearch: boolean, |
|
disabled: boolean, |
|
) => { |
|
const search = showSearch ? ( |
|
<div class={`${prefixCls}-body-search-wrapper`}> |
|
<Search |
|
prefixCls={`${prefixCls}-search`} |
|
onChange={handleFilter} |
|
handleClear={handleClear} |
|
placeholder={searchPlaceholder} |
|
value={filterValue.value} |
|
disabled={disabled} |
|
/> |
|
</div> |
|
) : null; |
|
|
|
let bodyNode: VNodeTypes; |
|
const { onEvents } = splitAttrs(attrs); |
|
const { bodyContent, customize } = renderListBody(renderList, { |
|
...props, |
|
filteredItems: filteredItems.value, |
|
filteredRenderItems: filteredRenderItems.value, |
|
selectedKeys: checkedKeys, |
|
...onEvents, |
|
}); |
|
|
|
// We should wrap customize list body in a classNamed div to use flex layout. |
|
if (customize) { |
|
bodyNode = <div class={`${prefixCls}-body-customize-wrapper`}>{bodyContent}</div>; |
|
} else { |
|
bodyNode = filteredItems.value.length ? ( |
|
bodyContent |
|
) : ( |
|
<div class={`${prefixCls}-body-not-found`}>{notFoundContentEle.value}</div> |
|
); |
|
} |
|
|
|
return ( |
|
<div |
|
class={ |
|
showSearch ? `${prefixCls}-body ${prefixCls}-body-with-search` : `${prefixCls}-body` |
|
} |
|
ref={transferNode} |
|
> |
|
{search} |
|
{bodyNode} |
|
</div> |
|
); |
|
}; |
|
|
|
return () => { |
|
const { |
|
prefixCls, |
|
checkedKeys, |
|
disabled, |
|
showSearch, |
|
searchPlaceholder, |
|
selectAll, |
|
selectCurrent, |
|
selectInvert, |
|
removeAll, |
|
removeCurrent, |
|
renderList, |
|
onItemSelectAll, |
|
onItemRemove, |
|
showSelectAll = true, |
|
showRemove, |
|
pagination, |
|
} = props; |
|
|
|
// Custom Layout |
|
const footerDom = slots.footer?.({ ...props }); |
|
|
|
const listCls = classNames(prefixCls, { |
|
[`${prefixCls}-with-pagination`]: !!pagination, |
|
[`${prefixCls}-with-footer`]: !!footerDom, |
|
}); |
|
|
|
// ================================= List Body ================================= |
|
|
|
const listBody = getListBody( |
|
prefixCls, |
|
searchPlaceholder, |
|
checkedKeys, |
|
renderList, |
|
showSearch, |
|
disabled, |
|
); |
|
|
|
const listFooter = footerDom ? <div class={`${prefixCls}-footer`}>{footerDom}</div> : null; |
|
|
|
const checkAllCheckbox = !showRemove && !pagination && getCheckBox({ disabled, prefixCls }); |
|
|
|
let menu = null; |
|
if (showRemove) { |
|
menu = ( |
|
<Menu> |
|
{/* Remove Current Page */} |
|
{pagination && ( |
|
<Menu.Item |
|
key="removeCurrent" |
|
onClick={() => { |
|
const pageKeys = getEnabledItemKeys( |
|
(defaultListBodyRef.value.items || []).map(entity => entity.item), |
|
); |
|
onItemRemove?.(pageKeys); |
|
}} |
|
> |
|
{removeCurrent} |
|
</Menu.Item> |
|
)} |
|
|
|
{/* Remove All */} |
|
<Menu.Item |
|
key="removeAll" |
|
onClick={() => { |
|
onItemRemove?.(enabledItemKeys.value); |
|
}} |
|
> |
|
{removeAll} |
|
</Menu.Item> |
|
</Menu> |
|
); |
|
} else { |
|
menu = ( |
|
<Menu> |
|
<Menu.Item |
|
key="selectAll" |
|
onClick={() => { |
|
const keys = enabledItemKeys.value; |
|
onItemSelectAll(getNewSelectKeys(keys, [])); |
|
}} |
|
> |
|
{selectAll} |
|
</Menu.Item> |
|
{pagination && ( |
|
<Menu.Item |
|
onClick={() => { |
|
const pageKeys = getEnabledItemKeys( |
|
(defaultListBodyRef.value.items || []).map(entity => entity.item), |
|
); |
|
onItemSelectAll(getNewSelectKeys(pageKeys, [])); |
|
}} |
|
> |
|
{selectCurrent} |
|
</Menu.Item> |
|
)} |
|
<Menu.Item |
|
key="selectInvert" |
|
onClick={() => { |
|
let availableKeys: string[]; |
|
if (pagination) { |
|
availableKeys = getEnabledItemKeys( |
|
(defaultListBodyRef.value.items || []).map(entity => entity.item), |
|
); |
|
} else { |
|
availableKeys = enabledItemKeys.value; |
|
} |
|
|
|
const checkedKeySet = new Set(checkedKeys); |
|
const newCheckedKeys: string[] = []; |
|
const newUnCheckedKeys: string[] = []; |
|
|
|
availableKeys.forEach(key => { |
|
if (checkedKeySet.has(key)) { |
|
newUnCheckedKeys.push(key); |
|
} else { |
|
newCheckedKeys.push(key); |
|
} |
|
}); |
|
onItemSelectAll(getNewSelectKeys(newCheckedKeys, newUnCheckedKeys)); |
|
}} |
|
> |
|
{selectInvert} |
|
</Menu.Item> |
|
</Menu> |
|
); |
|
} |
|
|
|
const dropdown = ( |
|
<Dropdown class={`${prefixCls}-header-dropdown`} overlay={menu} disabled={disabled}> |
|
<DownOutlined /> |
|
</Dropdown> |
|
); |
|
|
|
return ( |
|
<div class={listCls} style={attrs.style as CSSProperties}> |
|
<div class={`${prefixCls}-header`}> |
|
{showSelectAll ? ( |
|
<> |
|
{checkAllCheckbox} |
|
{dropdown} |
|
</> |
|
) : null} |
|
<span class={`${prefixCls}-header-selected`}> |
|
<span>{getSelectAllLabel(checkedKeys.length, filteredItems.value.length)}</span> |
|
<span class={`${prefixCls}-header-title`}>{slots.titleText?.()}</span> |
|
</span> |
|
</div> |
|
{listBody} |
|
{listFooter} |
|
</div> |
|
); |
|
}; |
|
}, |
|
});
|
|
|