import type { Ref } from 'vue' import { computed, isRef, shallowReactive } from 'vue' const htmlReplacers = { '': /<[^>]*>?/g, ' ': / /g, '"': /"/g, '\'': /'/g, '&': /&/g, '<': /</g, '>': />/g, } as const function clearHtml(value?: string): string { if (!value) { return '' } return Object.entries(htmlReplacers).reduce( (text, [replacement, regex]) => text.replace(regex, replacement), value ) } const normalizeSearchChars = /[\p{N}\p{L}\s]/gu function normalizeText(text: string): string { return text.match(normalizeSearchChars)?.join('').toLowerCase() ?? '' } function getValueByPath(obj: any, path: string): any { let result = obj for (const key of path.split('.')) { if (result === null || result === undefined || typeof result !== 'object') { return undefined } result = result[key] } return result } function collectAllLeafPaths(obj: any, prefix = ''): string[] { if (!obj || typeof obj !== 'object') { return [] } return Object.keys(obj).flatMap((key) => { const value = obj[key] const path = prefix ? `${prefix}.${key}` : key if (value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) { return collectAllLeafPaths(value, path) } return [path] }) } function toSearchableString(value: unknown): string { if (value === null || value === undefined) { return '' } if (typeof value === 'string') { return clearHtml(value) } if (typeof value === 'number' || typeof value === 'boolean') { return String(value) } if (value instanceof Date) { return value.toISOString() } return '' } interface FilterRefOptions> { filter?: Ref<(item: Item) => boolean> | ((item: Item) => boolean) /** Dot-path keys to index (e.g. 'user.address.city'). Defaults to all leaf fields. */ keys?: string[] minChars?: number } export function filterRef[]>( initialValue: T = [] as unknown as T, options: FilterRefOptions = {} ) { type Item = T[number] const { minChars = 1 } = options const state = shallowReactive({ _list: initialValue as Item[], searchTerm: '' }) let searchIndex: string[] = [] function buildIndexEntry(item: Item, seen = new WeakSet()): string { if (seen.has(item as object)) { return '' } seen.add(item as object) const paths = options.keys ?? collectAllLeafPaths(item) const parts: string[] = [] for (const path of paths) { const value = getValueByPath(item, path) if (Array.isArray(value)) { for (const el of value) { if (el && typeof el === 'object' && !(el instanceof Date)) { parts.push(buildIndexEntry(el as Item, seen)) } else { const s = toSearchableString(el) if (s) { parts.push(normalizeText(s)) } } } } else { const s = toSearchableString(value) if (s) { parts.push(normalizeText(s)) } } } return parts.join(' ') } function buildIndex(list: Item[]) { searchIndex = list.map(item => buildIndexEntry(item)) } buildIndex(initialValue as Item[]) const filtered = computed(() => { let result = state._list if (state.searchTerm.length >= minChars) { const terms = normalizeText(state.searchTerm).split(/\s+/).filter(t => t.length > 0) if (terms.length > 0) { result = result.filter((_, i) => { const entry = searchIndex[i] ?? '' return terms.every(term => entry.includes(term)) }) } } if (options.filter) { const fn = isRef(options.filter) ? options.filter.value : options.filter result = result.filter(fn) } return result }) return { get searchTerm() { return state.searchTerm }, set searchTerm(v: string) { state.searchTerm = v }, get value() { return filtered.value }, set value(v: Item[]) { state._list = v; buildIndex(v) }, get length() { return filtered.value.length }, [Symbol.iterator](): IterableIterator { return filtered.value[Symbol.iterator]() }, } }