veui
Version:
Baidu Enterprise UI for Vue.js.
253 lines (228 loc) • 7 kB
JavaScript
import { omit, escapeRegExp } from 'lodash'
import warn from '../utils/warn'
import useConfig from './config'
import config from '../managers/config'
const ZERO_WIDTH_JOINER = '\u200d'
function match (item, keyword, { searchKey, keywordRe }) {
let offsets = []
const searchVal = item[searchKey]
if (searchVal) {
let result = keywordRe.exec(searchVal)
while (result) {
const index = result.index
// 前后不是 ZERO_WIDTH_JOINER ,后面在正则里保证
if (!index || (index && searchVal[index - 1] !== ZERO_WIDTH_JOINER)) {
offsets.push([index, index + result[0].length])
}
result = keywordRe.exec(searchVal)
}
keywordRe.lastIndex = 0
}
return offsets.length ? offsets : false
}
function filter (item, keyword, { ancestors, offsets }) {
let matched = toBoolean(offsets)
return matched || ancestors.some(({ matched }) => matched)
}
function toBoolean (matchResult) {
return Array.isArray(matchResult) ? !!matchResult.length : matchResult
}
function createKeywordRe (keyword, { flags, literal }) {
keyword = literal ? escapeRegExp(keyword) : keyword
try {
return new RegExp(`${keyword}(?!${ZERO_WIDTH_JOINER})`, flags)
} catch (e) {
// keyword is not a valid regexp pattern or flags are invalid.
warn(`[veui-searchable] ${e.message}`)
return null
}
}
function splitText (text, offsets) {
let lastIndex = 0
let result = offsets.reduce((result, offset) => {
if (lastIndex < offset[0]) {
result.push({
text: text.slice(lastIndex, offset[0]),
matched: false
})
}
result.push({
text: text.slice(...offset),
matched: true
})
lastIndex = offset[1]
return result
}, [])
let rest = text.slice(lastIndex)
if (rest) {
result.push({
text: rest,
matched: false
})
}
return result
}
function search (datasource, keyword, options, result = []) {
let {
valueKey,
childrenKey,
matchFn,
filterFn,
limit,
searchKey,
ancestors
} = options
datasource.some((item) => {
// 包下不会怕属性冲突
let itemWrap = { item }
// match
let offsets = matchFn(
item,
keyword,
matchFn === match ? options : { ancestors }
)
let isArray = Array.isArray(offsets)
// 特殊处理下只有一段的匹配
if (
isArray &&
typeof offsets[0] === 'number' &&
typeof offsets[1] === 'number'
) {
offsets = [offsets]
}
let isBool = !isArray && typeof offsets === 'boolean'
if (!isArray && !isBool) {
throw new Error(
'The return value of the `match` function must either be a boolean or an array.'
)
}
itemWrap.matched = toBoolean(offsets)
// filter
let filtered = filterFn(item, keyword, { ancestors, offsets })
if (typeof filtered !== 'boolean') {
throw new Error(
'The return value of the `filter` function must be a boolean.'
)
}
itemWrap.filtered = filtered
// 即使没有匹配成功,为了渲染简单,还是生成 parts
if (item[searchKey]) {
itemWrap.parts = splitText(item[searchKey], isArray ? offsets : [])
}
const path = [...ancestors, itemWrap]
const disabled = path.some(({ item }) => !!item.disabled)
let limited = limit && limit <= result.length
if (!limited) {
if (item[valueKey] && itemWrap.filtered) {
result.push({
matches: path,
disabled,
...omit(item, childrenKey) // for flat optionGroup
})
}
if (item[childrenKey]) {
search(
item[childrenKey],
keyword,
{ ...options, ancestors: path },
result
)
// update limit after searching children
return limit && limit <= result.length
}
}
return limited
})
return result
}
const call = (val, context) => (typeof val === 'function' ? val(context) : val)
const CONFIG_NAMESPACE = 'searchable'
const CONTEXT_NAME = 'searchable_mixin_config'
// 声明出来,否则不响应式
config.defaults({
[`${CONFIG_NAMESPACE}.match`]: null,
[`${CONFIG_NAMESPACE}.filter`]: null
})
/**
* searchable mixin,产出一个 computed
*
* @param {Object} options
* @param {string} options.datasourceKey 输入的数据源的key
* @param {string} options.keywordKey 搜索关键字的key
* @param {string} options.resultKey 产出的 computed 的名称
* @param {string} options.matchKey 搜索方法
* @param {string} options.filterKey 过滤方法(影响匹配过程,比如将父匹配结果改为false,那么后代如果自己不匹配就不会在结果集中)
* @param {string} options.valueKey 该字段有值,该项才会作为搜索结果
* @param {string} options.childrenKey children
* @param {string} options.searchKey 被搜索的字段
* @param {number} options.limit 限制结果的数量,0 表示不限制
* @param {string} options.flags 正则的模式
* @param {boolean} options.literal 默认把 keyword 中的正则特殊字段当成普通字符匹配,如 \d 就是匹配`\d`
*/
export default function useSearchable ({
datasourceKey = 'datasource',
keywordKey = 'keyword',
resultKey = 'filteredDatasource',
valueKey = 'value',
childrenKey = 'children',
searchKey = 'label',
matchKey,
filterKey,
flags = 'ig',
limit = 300,
literal = true,
exposeProps = false
} = {}) {
if (exposeProps) {
matchKey = matchKey || 'match'
filterKey = filterKey || 'filter'
}
return {
...(exposeProps && {
// props 名字不一样,就自己暴露(exposeProps: false)
props: {
match: Function,
filter: Function
}
}),
mixins: [useConfig(CONTEXT_NAME, CONFIG_NAMESPACE)],
computed: {
[resultKey] () {
// 创建 RegExp
const keyword = this[call(keywordKey, this)]
if (!keyword) {
// 没有关键字就不要搜了,搜出来数据结构比较复杂
return []
}
const keywordRe = createKeywordRe(keyword, { flags, literal })
if (!keywordRe) {
return []
}
return search(this[call(datasourceKey, this)], keyword, {
valueKey: call(valueKey, this),
childrenKey: call(childrenKey, this),
searchKey: call(searchKey, this),
matchFn: getDefaultMatch(this, matchKey),
filterFn: getDefaultFilter(this, filterKey),
limit,
keywordRe,
ancestors: []
})
}
}
}
}
export function getDefaultFilter (vm, impl) {
return getConfigurable(vm, impl, 'filter') || filter
}
export function getDefaultMatch (vm, impl) {
return getConfigurable(vm, impl, 'match') || match
}
function getConfigurable (vm, impl, contextKey) {
if (typeof impl === 'string') {
impl = vm[impl]
}
return typeof impl === 'function'
? impl
: vm[CONTEXT_NAME][`${CONFIG_NAMESPACE}.${contextKey}`]
}