Skip to content

Commit 90fa23e

Browse files
J-Sekclaude
andcommitted
feat(VSelect): add ignore-accents filtering
Exposes `ignoreAccents` (`boolean | 'query' | 'target'`) on the shared filter props, so VSelect, VAutocomplete, VCombobox, VDataTable and other filterable components can match accent-insensitively. `defaultFilter` now emits index-mapped match ranges, so the highlighted spans stay correct. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2eb8821 commit 90fa23e

4 files changed

Lines changed: 57 additions & 18 deletions

File tree

packages/api-generator/src/locale/en/filter.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"customKeyFilter": "Function used on specific keys within the item object. `customFilter` is skipped for columns with `customKeyFilter` specified.",
55
"filterKeys": "Array of specific keys to filter on the item.",
66
"filterMode": "Controls how the results of `customFilter` and `customKeyFilter` are combined. All modes only apply `customFilter` to columns not specified in `customKeyFilter`.\n\n- **some**: There is at least one match from either the custom filter or the custom key filter.\n- **every**: All columns match either the custom filter or the custom key filter.\n- **union**: There is at least one match from the custom filter, or all columns match the custom key filters.\n- **intersection**: There is at least one match from the custom filter, and all columns match the custom key filters.",
7+
"ignoreAccents": "Folds accents before filtering. Use `'query'` to normalize only the search term, `'target'` to normalize only the item value, or `true` for both.",
78
"noFilter": "Disables all item filtering."
89
}
910
}

packages/docs/src/data/new-in.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
},
1414
"VAutocomplete": {
1515
"props": {
16+
"ignoreAccents": "4.2.0",
1617
"autocomplete": "3.10.0",
1718
"autoSelectFirst": "3.3.0",
1819
"clearOnSelect": "3.5.0",
@@ -109,6 +110,7 @@
109110
},
110111
"VCombobox": {
111112
"props": {
113+
"ignoreAccents": "4.2.0",
112114
"alwaysFilter": "3.10.4",
113115
"autocomplete": "3.10.0",
114116
"clearOnSelect": "3.5.0",
@@ -154,6 +156,7 @@
154156
},
155157
"VDataIterator": {
156158
"props": {
159+
"ignoreAccents": "4.2.0",
157160
"groupKey": "4.1.0",
158161
"initialSortOrder": "3.11.0",
159162
"openAll": "4.1.0",
@@ -168,6 +171,7 @@
168171
},
169172
"VDataTable": {
170173
"props": {
174+
"ignoreAccents": "4.2.0",
171175
"collapseIcon": "3.10.0",
172176
"expandIcon": "3.10.0",
173177
"expandStrategy": "4.1.0",
@@ -227,6 +231,7 @@
227231
},
228232
"VDataTableVirtual": {
229233
"props": {
234+
"ignoreAccents": "4.2.0",
230235
"collapseIcon": "3.10.0",
231236
"expandIcon": "3.10.0",
232237
"expandStrategy": "4.1.0",
@@ -431,6 +436,7 @@
431436
},
432437
"VSelect": {
433438
"props": {
439+
"ignoreAccents": "4.2.0",
434440
"autocomplete": "3.10.0",
435441
"listProps": "3.5.0",
436442
"menuElevation": "4.0.0",
@@ -592,6 +598,7 @@
592598
},
593599
"VTreeview": {
594600
"props": {
601+
"ignoreAccents": "4.2.0",
595602
"hideActions": "3.9.0",
596603
"hideNoData": "3.10.0",
597604
"noDataText": "3.10.0",

packages/vuetify/src/composables/__tests__/filter.spec.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Composables
2-
import { defaultFilter, filterItems, useFilter } from '../filter'
2+
import { createDefaultFilter, defaultFilter, filterItems, useFilter } from '../filter'
33
import { transformItem, transformItems } from '../list-items'
44

55
// Utilities
@@ -31,6 +31,34 @@ describe('filter', () => {
3131
// @ts-expect-error
3232
expect(defaultFilter(text, query)).toStrictEqual(expected)
3333
})
34+
35+
it('does not fold accents by default', () => {
36+
expect(defaultFilter('café', 'cafe')).toBe(-1)
37+
})
38+
39+
describe('ignoreAccents', () => {
40+
it("folds both sides when true ('cafe' finds 'café', ranges map to the original)", () => {
41+
expect(createDefaultFilter(true)('café', 'cafe')).toStrictEqual([[0, 4]])
42+
})
43+
44+
it('maps ranges back to a decomposed source string', () => {
45+
const decomposed = 'cafe\u0301' // 'cafe' + combining acute => 5 code units
46+
expect(decomposed).toHaveLength(5)
47+
expect(createDefaultFilter(true)(decomposed, 'cafe')).toStrictEqual([[0, 5]])
48+
})
49+
50+
it("'target' finds accented entries from a plain query", () => {
51+
expect(createDefaultFilter('target')('Łódź', 'odz')).toStrictEqual([[1, 4]])
52+
})
53+
54+
it("'target' keeps the query accented", () => {
55+
expect(createDefaultFilter('target')('London', 'Łó')).toBe(-1)
56+
})
57+
58+
it("'query' folds an accented query to match plain text", () => {
59+
expect(createDefaultFilter('query')('cafe', 'café')).toStrictEqual([[0, 4]])
60+
})
61+
})
3462
})
3563

3664
describe('filterItems', () => {

packages/vuetify/src/composables/filter.tsx

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33

44
// Utilities
55
import { computed, shallowRef, unref, watchEffect } from 'vue'
6-
import { getPropertyFromItem, propsFactory, wrapInArray } from '@/util'
6+
import { findMatchRanges, getPropertyFromItem, propsFactory, wrapInArray } from '@/util'
77

88
// Types
99
import type { PropType, Ref } from 'vue'
10-
import type { MaybeRef } from '@/util'
10+
import type { IgnoreAccents, MaybeRef } from '@/util'
1111

1212
/**
1313
* - boolean: match without highlight
@@ -29,6 +29,7 @@ export interface FilterProps {
2929
customKeyFilter?: FilterKeyFunctions
3030
filterKeys?: FilterKeys
3131
filterMode?: FilterMode
32+
ignoreAccents?: IgnoreAccents
3233
noFilter?: boolean
3334
}
3435

@@ -45,24 +46,23 @@ type FilterResult = {
4546
}
4647

4748
// Composables
48-
export const defaultFilter: FilterFunction = (value, query, item) => {
49-
if (value == null || query == null) return -1
50-
if (!query.length) return 0
51-
52-
value = value.toString().toLocaleLowerCase()
53-
query = query.toString().toLocaleLowerCase()
54-
55-
const result = []
56-
let idx = value.indexOf(query)
57-
while (~idx) {
58-
result.push([idx, idx + query.length] as const)
49+
export function createDefaultFilter (ignoreAccents?: IgnoreAccents): FilterFunction {
50+
return (value, query) => {
51+
if (value == null || query == null) return -1
52+
if (!query.length) return 0
53+
54+
const ranges = findMatchRanges(value.toString(), query.toString(), {
55+
ignoreCase: true,
56+
ignoreAccents,
57+
all: true,
58+
})
5959

60-
idx = value.indexOf(query, idx + query.length)
60+
return ranges.length ? ranges : -1
6161
}
62-
63-
return result.length ? result : -1
6462
}
6563

64+
export const defaultFilter: FilterFunction = createDefaultFilter()
65+
6666
function normaliseMatch (match: FilterMatch, query: string): FilterMatchArrayMultiple | undefined {
6767
if (match == null || typeof match === 'boolean' || match === -1) return
6868
if (typeof match === 'number') return [[match, match + query.length]]
@@ -78,6 +78,7 @@ export const makeFilterProps = propsFactory({
7878
type: String as PropType<FilterMode>,
7979
default: 'intersection',
8080
},
81+
ignoreAccents: [Boolean, String] as PropType<IgnoreAccents>,
8182
noFilter: Boolean,
8283
}, 'filter')
8384

@@ -90,12 +91,13 @@ export function filterItems (
9091
default?: FilterFunction
9192
filterKeys?: FilterKeys
9293
filterMode?: FilterMode
94+
ignoreAccents?: IgnoreAccents
9395
noFilter?: boolean
9496
},
9597
) {
9698
const array: FilterResult[] = []
9799
// always ensure we fall back to a functioning filter
98-
const filter = options?.default ?? defaultFilter
100+
const filter = options?.default ?? createDefaultFilter(options?.ignoreAccents)
99101
const keys = options?.filterKeys ? wrapInArray(options.filterKeys) : false
100102
const customFiltersLength = Object.keys(options?.customKeyFilter ?? {}).length
101103

@@ -215,6 +217,7 @@ export function useFilter <T extends InternalItem> (
215217
default: props.customFilter,
216218
filterKeys: props.filterKeys,
217219
filterMode: props.filterMode,
220+
ignoreAccents: props.ignoreAccents,
218221
noFilter: props.noFilter,
219222
},
220223
)

0 commit comments

Comments
 (0)