diff --git a/.changeset/highlight-ignore-accents.md b/.changeset/highlight-ignore-accents.md
new file mode 100644
index 0000000000..0a6cf56692
--- /dev/null
+++ b/.changeset/highlight-ignore-accents.md
@@ -0,0 +1,11 @@
+---
+"@vuetify/v0": minor
+---
+
+feat(toHighlight): add `ignoreAccents` so a plain query matches accented text
+
+`toHighlight(text, query, { ignoreCase: true, ignoreAccents: true })` folds diacritics before matching and maps the ranges back onto the source, so `zurich` highlights *Zürich* with its umlaut intact. It is directional — `'target'` folds only the text, `'query'` only the search term, `true` both sides — and covers common letters NFD leaves alone (`ł`, `ø`, `ß`, `æ`, …).
+
+With `ignoreCase`, Greek final sigma folds to `σ` so `ΣΟΦΟΣ` and `σοφος` match each other.
+
+The matcher behind it ships as `findMatchRanges(text, query, { ignoreCase, ignoreAccents, matchAll })` for filters that need the same ranges without the chunking.
diff --git a/apps/docs/src/examples/composables/to-highlight/accents.vue b/apps/docs/src/examples/composables/to-highlight/accents.vue
new file mode 100644
index 0000000000..ba754457e4
--- /dev/null
+++ b/apps/docs/src/examples/composables/to-highlight/accents.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ hints[mode] }}
+
+
+
+
+ {{ chunk.text }}
+ {{ chunk.text }}
+
+
+
+
+
diff --git a/apps/docs/src/pages/composables/transformers/to-highlight.md b/apps/docs/src/pages/composables/transformers/to-highlight.md
index ee557d2f4d..53975d5f8a 100644
--- a/apps/docs/src/pages/composables/transformers/to-highlight.md
+++ b/apps/docs/src/pages/composables/transformers/to-highlight.md
@@ -130,6 +130,23 @@ before chunking, so unordered or overlapping input is normalized for you.
:::
+## Recipes
+
+### Accent-insensitive search
+
+`ignoreAccents` folds diacritics before matching, then maps the ranges back onto the source
+string — the rendered chunks keep their original characters. Pair it with `ignoreCase` when
+the query and text may differ in case: `'target'` folds only the text so `zurich` reaches
+*Zürich*, `'query'` folds only the search term so a pasted `café` reaches plain `cafe`, and
+`true` folds both sides.
+
+Common letters that NFD leaves untouched are folded as well (`ł → l`, `ø → o`, `ß → ss`, `æ → ae`), and a
+fold that changes length still reports ranges into the original text.
+
+::: gn-example
+/composables/to-highlight/accents
+:::
+
## Accessibility
Wrap matched chunks in the native `` element. It carries the implicit ARIA role
@@ -153,6 +170,16 @@ Yes. The source `text` string is sliced at match boundaries, so the original cha
Yes. The `matches` option accepts `MatchRange[]` — `[start, end]` pairs. Once
`createFilter` exposes positional data, pass the result directly and skip the query path.
+`createFilter` does not fold accents; `toHighlight(..., { ignoreAccents: true })` will
+mark rows the filter still drops. Use [findMatchRanges](/guide/features/utilities#findmatchranges)
+on the filter path if both sides must agree.
+
+??? Does accent folding change the highlighted text?
+
+No. Folding happens on a working copy; the returned chunks are always slices of the source
+`text`, so `Zürich` renders with its umlaut even when the query was `zurich`. A fold that changes
+length — `ß → ss`, or a decomposed `e` + combining acute — is mapped back to the characters it
+came from.
??? How does it handle overlapping multi-term matches?
diff --git a/apps/docs/src/pages/guide/features/utilities.md b/apps/docs/src/pages/guide/features/utilities.md
index 06e813720b..406003550d 100644
--- a/apps/docs/src/pages/guide/features/utilities.md
+++ b/apps/docs/src/pages/guide/features/utilities.md
@@ -477,6 +477,26 @@ range(5, 10) // [10, 11, 12, 13, 14]
range(0) // []
```
+### findMatchRanges
+
+Locate a query inside a string and get back `[start, end]` pairs, optionally folding accents before matching. This is what backs the `ignoreAccents` option of [toHighlight](/composables/transformers/to-highlight):
+
+```ts
+import { findMatchRanges } from '@vuetify/v0'
+
+findMatchRanges('Zürich', 'zurich', { ignoreCase: true, ignoreAccents: true }) // [[0, 6]]
+findMatchRanges('Zürich', 'zurich', { ignoreCase: true }) // []
+```
+
+`ignoreAccents` is directional. `'target'` folds only the text, so a plain query reaches accented entries; `'query'` folds only the search term, so a pasted `café` reaches plain `cafe`; `true` folds both sides.
+
+```ts
+findMatchRanges('Łódź', 'Lo', { ignoreAccents: 'target' }) // [[0, 2]]
+findMatchRanges('cafe', 'café', { ignoreAccents: 'query' }) // [[0, 4]]
+```
+
+Folding strips Combining Diacritical Marks (U+0300–036F) after NFD, then common letters NFD leaves alone (`ł`, `ø`, `đ`, `ß`, `æ`, `œ`, …). When the text is folded or case-converted, ranges are mapped back onto the source — `ß` → `ss` still spans one source character, and `İ` still spans one. Pass `matchAll: true` for every occurrence instead of the first.
+
### pxToNumber
Parse a CSS pixel length into a number. Built for reading `getComputedStyle` output, where a length that does not apply resolves to `''` or `'auto'` rather than to a number:
@@ -534,4 +554,3 @@ const id = useId() // 'v:0' (Vue's format)
// Outside component
const id = useId() // 'v0-0', 'v0-1', ...
```
-
diff --git a/dev/src/composables.d.ts b/dev/src/composables.d.ts
index a40b7e11c1..2cca0c77c4 100644
--- a/dev/src/composables.d.ts
+++ b/dev/src/composables.d.ts
@@ -182,6 +182,7 @@ declare global {
const defineComponent: typeof import('vue').defineComponent
const effectScope: typeof import('vue').effectScope
const extractLeaves: typeof import('../../packages/0/src/composables/createDataTable/index').extractLeaves
+ const findMatchRanges: typeof import('../../packages/0/src/utilities/diacritics').findMatchRanges
const flatten: typeof import('../../packages/0/src/composables/createTokens/index').flatten
const foreground: typeof import('../../packages/0/src/utilities/apca').foreground
const genId: typeof import('../../packages/0/src/utilities/helpers').genId
@@ -473,7 +474,7 @@ declare global {
export type { MaybeElementRef } from '../../packages/0/src/composables/toElement/index'
import('../../packages/0/src/composables/toElement/index')
// @ts-ignore
- export type { MatchRange, HighlightChunk, ToHighlightOptions } from '../../packages/0/src/composables/toHighlight/index'
+ export type { HighlightChunk, ToHighlightOptions } from '../../packages/0/src/composables/toHighlight/index'
import('../../packages/0/src/composables/toHighlight/index')
// @ts-ignore
export type { BreakpointName, BreakpointsContext, BreakpointsOptions, BreakpointsPluginOptions, BreakpointsContextOptions } from '../../packages/0/src/composables/useBreakpoints/index'
@@ -587,6 +588,9 @@ declare global {
export type { RGB } from '../../packages/0/src/utilities/color'
import('../../packages/0/src/utilities/color')
// @ts-ignore
+ export type { MatchRange, IgnoreAccents, FindMatchRangesOptions } from '../../packages/0/src/utilities/diacritics'
+ import('../../packages/0/src/utilities/diacritics')
+ // @ts-ignore
export type { V0Error, V0Error } from '../../packages/0/src/utilities/errors'
import('../../packages/0/src/utilities/errors')
}
@@ -753,6 +757,7 @@ declare module 'vue' {
readonly defineComponent: UnwrapRef
readonly effectScope: UnwrapRef
readonly extractLeaves: UnwrapRef
+ readonly findMatchRanges: UnwrapRef
readonly flatten: UnwrapRef
readonly foreground: UnwrapRef
readonly getCurrentInstance: UnwrapRef
diff --git a/packages/0/src/composables/toHighlight/index.test.ts b/packages/0/src/composables/toHighlight/index.test.ts
index 011686d3c1..16180f0089 100644
--- a/packages/0/src/composables/toHighlight/index.test.ts
+++ b/packages/0/src/composables/toHighlight/index.test.ts
@@ -132,6 +132,76 @@ describe('toHighlight', () => {
})
})
+ describe('accent folding', () => {
+ function matched (chunks: { text: string, match: boolean }[]) {
+ return chunks.filter(chunk => chunk.match).map(chunk => chunk.text)
+ }
+
+ it('should not fold accents by default', () => {
+ expect(toHighlight('café', 'cafe')).toStrictEqual([{ text: 'café', match: false }])
+ })
+
+ it('should fold both sides when ignoreAccents is true', () => {
+ expect(matched(toHighlight('a café here', 'cafe', { ignoreAccents: true }))).toStrictEqual(['café'])
+ expect(matched(toHighlight('a cafe here', 'café', { ignoreAccents: true }))).toStrictEqual(['cafe'])
+ })
+
+ it('should preserve the original text when the source is decomposed', () => {
+ const decomposed = 'cafe\u0301'
+
+ expect(matched(toHighlight(`a ${decomposed} here`, 'cafe', { ignoreAccents: true })))
+ .toStrictEqual([decomposed])
+ })
+
+ it('should fold only the text when target', () => {
+ expect(matched(toHighlight('café', 'cafe', { ignoreAccents: 'target' }))).toStrictEqual(['café'])
+ expect(toHighlight('cafe', 'café', { ignoreAccents: 'target' }))
+ .toStrictEqual([{ text: 'cafe', match: false }])
+ })
+
+ it('should fold only the query when query', () => {
+ expect(matched(toHighlight('cafe', 'café', { ignoreAccents: 'query' }))).toStrictEqual(['cafe'])
+ })
+
+ it('should combine with ignoreCase and matchAll', () => {
+ expect(matched(toHighlight('Zürich, zurich', 'zurich', {
+ ignoreAccents: true,
+ ignoreCase: true,
+ matchAll: true,
+ }))).toStrictEqual(['Zürich', 'zurich'])
+ })
+
+ it('should map ignoreCase expansion back onto the original character', () => {
+ expect(toHighlight('İstanbul', 'İ', { ignoreCase: true })).toStrictEqual([
+ { text: 'İ', match: true },
+ { text: 'stanbul', match: false },
+ ])
+ expect(toHighlight('İstanbul', 'i', { ignoreCase: true })).toStrictEqual([
+ { text: 'İ', match: true },
+ { text: 'stanbul', match: false },
+ ])
+ })
+
+ it('should expand a half-fold hit to the whole source character', () => {
+ expect(toHighlight('straße', 's', { ignoreAccents: true, matchAll: true })).toStrictEqual([
+ { text: 's', match: true },
+ { text: 'tra', match: false },
+ { text: 'ß', match: true },
+ { text: 'e', match: false },
+ ])
+ })
+
+ it('should accept a getter for ignoreAccents', () => {
+ const ignoreAccents = shallowRef<'target' | false>(false)
+ const chunks = computed(() => toHighlight('Tromsø', 'tromso', { ignoreCase: true, ignoreAccents }))
+
+ expect(matched(chunks.value)).toStrictEqual([])
+
+ ignoreAccents.value = 'target'
+ expect(matched(chunks.value)).toStrictEqual(['Tromsø'])
+ })
+ })
+
describe('priority and fallthrough', () => {
it('should use pre-computed matches over query when both are provided', () => {
expect(toHighlight('hello', 'hello', { matches: [[1, 3]] })).toStrictEqual([
diff --git a/packages/0/src/composables/toHighlight/index.ts b/packages/0/src/composables/toHighlight/index.ts
index f33028a9f5..da311e6525 100644
--- a/packages/0/src/composables/toHighlight/index.ts
+++ b/packages/0/src/composables/toHighlight/index.ts
@@ -22,23 +22,14 @@
import { toArray } from '#v0/composables/toArray'
// Utilities
+import { findMatchRanges } from '#v0/utilities'
import { toValue } from 'vue'
// Types
+import type { IgnoreAccents, MatchRange as Range } from '#v0/utilities'
import type { MaybeRefOrGetter } from 'vue'
-/**
- * A `[start, end]` index pair where `end` is exclusive (matches
- * `String.prototype.slice` convention).
- *
- * @example
- * ```ts
- * import type { MatchRange } from '@vuetify/v0'
- *
- * const ranges: MatchRange[] = [[0, 5], [12, 17]]
- * ```
- */
-export type MatchRange = readonly [number, number]
+export type { MatchRange } from '#v0/utilities'
/**
* A contiguous chunk of source text, flagged as matched or unmatched.
@@ -75,7 +66,7 @@ export interface ToHighlightOptions {
* Caller-supplied ranges are sorted and merged before chunking, so
* unsorted or overlapping input is handled gracefully.
*/
- matches?: MaybeRefOrGetter
+ matches?: MaybeRefOrGetter
/**
* Highlight every occurrence (`true`) or only the first per term (`false`, default).
* Ignored when `matches` is provided.
@@ -83,9 +74,14 @@ export interface ToHighlightOptions {
matchAll?: MaybeRefOrGetter
/** Case-insensitive matching. Default `false`. */
ignoreCase?: MaybeRefOrGetter
+ /**
+ * Folds accents before matching. `'query'` folds only the search term,
+ * `'target'` only the text, `true` both sides. Default `false`.
+ */
+ ignoreAccents?: MaybeRefOrGetter
}
-function merge (ranges: readonly MatchRange[]): MatchRange[] {
+function merge (ranges: readonly Range[]): Range[] {
const sorted = ranges
.filter(span => span[0] < span[1])
.toSorted((a, b) => a[0] - b[0])
@@ -100,7 +96,7 @@ function merge (ranges: readonly MatchRange[]): MatchRange[] {
return merged
}
-function chunk (text: string, ranges: readonly MatchRange[]): HighlightChunk[] {
+function chunk (text: string, ranges: readonly Range[]): HighlightChunk[] {
const chunks: HighlightChunk[] = []
let cursor = 0
@@ -115,25 +111,15 @@ function chunk (text: string, ranges: readonly MatchRange[]): HighlightChunk[] {
return chunks
}
-function find (text: string, query: string | string[], matchAll: boolean, ignoreCase: boolean): MatchRange[] {
- const terms = toArray(query).filter(Boolean)
- const haystack = ignoreCase ? text.toLocaleLowerCase() : text
- const spans: [number, number][] = []
-
- for (const term of terms) {
- const needle = ignoreCase ? term.toLocaleLowerCase() : term
- let index = haystack.indexOf(needle)
-
- if (index !== -1) {
- spans.push([index, index + term.length])
- if (matchAll) {
- index = haystack.indexOf(needle, index + term.length)
- while (index !== -1) {
- spans.push([index, index + term.length])
- index = haystack.indexOf(needle, index + term.length)
- }
- }
- }
+function find (
+ text: string,
+ query: string | string[],
+ options: { matchAll: boolean, ignoreCase: boolean, ignoreAccents: IgnoreAccents },
+): Range[] {
+ const spans: Range[] = []
+
+ for (const term of toArray(query).filter(Boolean)) {
+ spans.push(...findMatchRanges(text, term, options))
}
return merge(spans)
@@ -149,7 +135,7 @@ function find (text: string, query: string | string[], matchAll: boolean, ignore
*
* @param text The source string to split.
* @param query One or more search terms. Empty strings are ignored. Case sensitivity controlled by `options.ignoreCase`.
- * @param options Optional `matches`, `matchAll`, `ignoreCase`.
+ * @param options Optional `matches`, `matchAll`, `ignoreCase`, `ignoreAccents`.
* @returns A `HighlightChunk[]` array.
*
* @see https://0.vuetifyjs.com/composables/transformers/to-highlight
@@ -177,11 +163,12 @@ export function toHighlight (
const _matches = toValue(options.matches)
const matchAll = toValue(options.matchAll) ?? false
const ignoreCase = toValue(options.ignoreCase) ?? false
+ const ignoreAccents = toValue(options.ignoreAccents) ?? false
if (_matches?.length) return chunk(_text, merge(_matches))
if (_query) {
- const ranges = find(_text, _query, matchAll, ignoreCase)
+ const ranges = find(_text, _query, { matchAll, ignoreCase, ignoreAccents })
return ranges.length > 0 ? chunk(_text, ranges) : [{ text: _text, match: false }]
}
diff --git a/packages/0/src/maturity.json b/packages/0/src/maturity.json
index bd596f84ba..39dfa780d3 100644
--- a/packages/0/src/maturity.json
+++ b/packages/0/src/maturity.json
@@ -736,6 +736,12 @@
"since": "0.0.15",
"category": "utilities"
},
+ "findMatchRanges": {
+ "level": "preview",
+ "since": null,
+ "category": "utilities",
+ "description": "Finds match ranges of a query in a string, optionally folding accents on either side."
+ },
"foreground": {
"level": "preview",
"since": "0.1.11",
diff --git a/packages/0/src/surface.test.ts b/packages/0/src/surface.test.ts
index 729efea085..ee65d9e6f5 100644
--- a/packages/0/src/surface.test.ts
+++ b/packages/0/src/surface.test.ts
@@ -44,7 +44,7 @@ const COMPONENTS = [
]
const UTILITIES = [
- 'UNSAFE_KEYS', 'V0Error', 'apca', 'clamp', 'foreground', 'getActiveElement', 'hexToRgb', 'instanceExists', 'instanceName', 'isArray', 'isBoolean', 'isElement', 'isFunction', 'isNaN', 'isNull', 'isNullOrUndefined', 'isNumber', 'isObject', 'isPrimitive', 'isString', 'isSymbol', 'isThenable', 'isUndefined', 'isV0Error', 'mergeDeep', 'pxToNumber', 'range', 'resolveIds', 'resolveIndexes', 'rgbToHex', 'useId',
+ 'UNSAFE_KEYS', 'V0Error', 'apca', 'clamp', 'findMatchRanges', 'foreground', 'getActiveElement', 'hexToRgb', 'instanceExists', 'instanceName', 'isArray', 'isBoolean', 'isElement', 'isFunction', 'isNaN', 'isNull', 'isNullOrUndefined', 'isNumber', 'isObject', 'isPrimitive', 'isString', 'isSymbol', 'isThenable', 'isUndefined', 'isV0Error', 'mergeDeep', 'pxToNumber', 'range', 'resolveIds', 'resolveIndexes', 'rgbToHex', 'useId',
]
describe('public surface', () => {
diff --git a/packages/0/src/utilities/diacritics.test.ts b/packages/0/src/utilities/diacritics.test.ts
new file mode 100644
index 0000000000..83cb552771
--- /dev/null
+++ b/packages/0/src/utilities/diacritics.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, it } from 'vitest'
+
+// Utilities
+import { findMatchRanges } from './diacritics'
+
+describe('diacritics', () => {
+ describe('findMatchRanges', () => {
+ it('should match exactly when ignoreAccents is off', () => {
+ expect(findMatchRanges('café', 'cafe', { matchAll: true })).toStrictEqual([])
+ expect(findMatchRanges('cafe', 'cafe', { matchAll: true })).toStrictEqual([[0, 4]])
+ })
+
+ it('should return no ranges for an empty query', () => {
+ expect(findMatchRanges('café', '', { ignoreAccents: true })).toStrictEqual([])
+ })
+
+ it('should return no ranges when the query folds to empty', () => {
+ expect(findMatchRanges('café', '\u0301', { ignoreAccents: true })).toStrictEqual([])
+ expect(findMatchRanges('café', '\u0301', { ignoreAccents: true, matchAll: true })).toStrictEqual([])
+ })
+
+ it('should fold both sides when true', () => {
+ expect(findMatchRanges('café', 'cafe', { ignoreAccents: true })).toStrictEqual([[0, 4]])
+ expect(findMatchRanges('cafe', 'café', { ignoreAccents: true })).toStrictEqual([[0, 4]])
+ expect(findMatchRanges('café', 'cafe\u0301', { ignoreAccents: true })).toStrictEqual([[0, 4]])
+ expect(findMatchRanges('café', 'cafè', { ignoreAccents: true })).toStrictEqual([[0, 4]])
+ expect(findMatchRanges('café', 'cafè', { ignoreAccents: 'target' })).toStrictEqual([])
+ expect(findMatchRanges('café', 'cafè', { ignoreAccents: 'query' })).toStrictEqual([])
+ })
+
+ it('should map ranges back onto a decomposed source', () => {
+ const decomposed = 'cafe\u0301' // e + combining acute, 5 code units
+
+ expect(decomposed).toHaveLength(5)
+ expect(findMatchRanges(decomposed, 'cafe', { ignoreAccents: true })).toStrictEqual([[0, 5]])
+ })
+
+ it('should fold only the text when target', () => {
+ expect(findMatchRanges('café', 'cafe', { ignoreAccents: 'target' })).toStrictEqual([[0, 4]])
+ expect(findMatchRanges('cafe', 'café', { ignoreAccents: 'target' })).toStrictEqual([])
+ })
+
+ it('should fold only the query when query', () => {
+ expect(findMatchRanges('cafe', 'café', { ignoreAccents: 'query' })).toStrictEqual([[0, 4]])
+ expect(findMatchRanges('café', 'cafe', { ignoreAccents: 'query' })).toStrictEqual([])
+ })
+
+ it('should find the first occurrence only unless matchAll is set', () => {
+ expect(findMatchRanges('é é', 'e', { ignoreAccents: true })).toStrictEqual([[0, 1]])
+ expect(findMatchRanges('é é', 'e', { ignoreAccents: true, matchAll: true })).toStrictEqual([[0, 1], [2, 3]])
+ })
+
+ it('should combine folding with case-insensitivity', () => {
+ expect(findMatchRanges('RÉSUMÉ', 'resume', { ignoreAccents: true, ignoreCase: true }))
+ .toStrictEqual([[0, 6]])
+ })
+
+ it('should stay case-sensitive unless ignoreCase is set', () => {
+ expect(findMatchRanges('RÉSUMÉ', 'resume', { ignoreAccents: true })).toStrictEqual([])
+ })
+
+ it('should fold letters that NFD leaves untouched', () => {
+ expect(findMatchRanges('Łódź', 'lodz', { ignoreAccents: true, ignoreCase: true }))
+ .toStrictEqual([[0, 4]])
+ expect(findMatchRanges('Łódź', 'Lo', { ignoreAccents: 'target' })).toStrictEqual([[0, 2]])
+ })
+
+ it('should map ranges back across multi-character folds', () => {
+ expect(findMatchRanges('straße', 'strasse', { ignoreAccents: true })).toStrictEqual([[0, 6]])
+ })
+
+ it('should expand a half-fold hit to the whole source character', () => {
+ expect(findMatchRanges('ß', 's', { ignoreAccents: true })).toStrictEqual([[0, 1]])
+ expect(findMatchRanges('straße', 's', { ignoreAccents: true, matchAll: true }))
+ .toStrictEqual([[0, 1], [4, 5]])
+ expect(findMatchRanges('straße', 'stras', { ignoreAccents: true })).toStrictEqual([[0, 5]])
+ })
+
+ it('should map case-expansion back onto the original character', () => {
+ expect(findMatchRanges('İstanbul', 'İ', { ignoreCase: true })).toStrictEqual([[0, 1]])
+ expect(findMatchRanges('İstanbul', 'i', { ignoreCase: true })).toStrictEqual([[0, 1]])
+ expect(findMatchRanges('İstanbul', 'stan', { ignoreCase: true })).toStrictEqual([[1, 5]])
+ })
+
+ it('should treat Greek final sigma as sigma when ignoreCase is set', () => {
+ expect(findMatchRanges('ΣΟΦΟΣ', 'σοφος', { ignoreCase: true })).toStrictEqual([[0, 5]])
+ expect(findMatchRanges('σοφος', 'ΣΟΦΟΣ', { ignoreCase: true })).toStrictEqual([[0, 5]])
+ expect(findMatchRanges('ΣΟΦΟΣ', 'σοφος', { ignoreCase: true, ignoreAccents: true })).toStrictEqual([[0, 5]])
+ expect(findMatchRanges('ΣΟΦΟΣ', 'σοφος')).toStrictEqual([])
+ })
+
+ describe('astral characters (surrogate pairs)', () => {
+ // 3 capital Adlam letters (U+1E900..) case-fold to their lowercase forms.
+ const adlam = String.fromCodePoint(0x1_E9_00, 0x1_E9_01, 0x1_E9_02)
+ // CJK compatibility ideograph (U+2F800), NFD-decomposes to U+4E3D.
+ const cjkCompat = String.fromCodePoint(0x2_F8_00)
+
+ it('should case-fold an astral bicameral script', () => {
+ expect(findMatchRanges(adlam, adlam.toLowerCase(), { ignoreAccents: true, ignoreCase: true }))
+ .toStrictEqual([[0, adlam.length]])
+ })
+
+ it('should decompose an astral NFD character back to its base', () => {
+ expect(findMatchRanges(cjkCompat, cjkCompat.normalize('NFD'), { ignoreAccents: true }))
+ .toStrictEqual([[0, cjkCompat.length]])
+ })
+
+ it('should keep emoji intact and map ranges past them', () => {
+ expect(findMatchRanges('👍 café', 'cafe', { ignoreAccents: true })).toStrictEqual([[3, 7]])
+ expect(findMatchRanges('a👍b', 'b', { ignoreAccents: true })).toStrictEqual([[3, 4]])
+ })
+ })
+ })
+})
diff --git a/packages/0/src/utilities/diacritics.ts b/packages/0/src/utilities/diacritics.ts
new file mode 100644
index 0000000000..184adf224f
--- /dev/null
+++ b/packages/0/src/utilities/diacritics.ts
@@ -0,0 +1,214 @@
+/**
+ * @module utilities/diacritics
+ *
+ * @remarks
+ * Accent-folding search primitives. Pure string transforms — no DOM, no state,
+ * no reactivity. Powers the `ignoreAccents` option of `toHighlight` and any
+ * filter that wants a plain query to reach accented text.
+ */
+
+/**
+ * A `[start, end]` index pair where `end` is exclusive (matches
+ * `String.prototype.slice` convention).
+ *
+ * @example
+ * ```ts
+ * import type { MatchRange } from '@vuetify/v0'
+ *
+ * const ranges: MatchRange[] = [[0, 5], [12, 17]]
+ * ```
+ */
+export type MatchRange = readonly [number, number]
+
+/**
+ * Which side of a comparison has its accents folded before matching.
+ *
+ * - `'query'` — only the query, so typing `café` finds plain `cafe`
+ * - `'target'` — only the text, so typing `cafe` finds accented `café`
+ * - `true` — both sides
+ * - `false` — neither
+ */
+export type IgnoreAccents = boolean | 'query' | 'target'
+
+export interface FindMatchRangesOptions {
+ ignoreCase?: boolean
+ ignoreAccents?: IgnoreAccents
+ matchAll?: boolean
+}
+
+const COMBINING_MARKS = /[\u0300-\u036F]/g
+const FINAL_SIGMA = /ς/g
+
+// ς is σ when used at the end of a word - both uppercase to Σ
+// a per-character loop with casing change cannot see it
+function lower (str: string): string {
+ return str.toLowerCase().replace(FINAL_SIGMA, 'σ')
+}
+
+// No canonical NFD decomposition, so they need a manual map.
+const SPECIAL_LETTERS: Record = {
+ ł: 'l',
+ ø: 'o',
+ đ: 'd',
+ ð: 'd',
+ þ: 'th',
+ ħ: 'h',
+ ŧ: 't',
+ ŋ: 'n',
+ ß: 'ss',
+ æ: 'ae',
+ œ: 'oe',
+ ı: 'i',
+ Ł: 'L',
+ Ø: 'O',
+ Đ: 'D',
+ Ð: 'D',
+ Þ: 'Th',
+ Ħ: 'H',
+ Ŧ: 'T',
+ Ŋ: 'N',
+ ẞ: 'Ss',
+ Æ: 'Ae',
+ Œ: 'Oe',
+}
+
+const SPECIAL_LETTER = /* @__PURE__ */ new RegExp(`[${Object.keys(SPECIAL_LETTERS).join('')}]`, 'g')
+
+function fold (str: string): string {
+ return str
+ .normalize('NFD')
+ .replace(COMBINING_MARKS, '')
+ .replace(SPECIAL_LETTER, char => SPECIAL_LETTERS[char]!)
+}
+
+// Records the source index each output unit came from, so ranges found in the
+// transformed string can be mapped back onto the original. Mark-stripping,
+// special-letter expansion (ß→ss), and toLowerCase (İ→i̇) all change length.
+function foldWithMap (str: string, ignoreCase: boolean, foldAccents: boolean) {
+ let folded = ''
+ const map: number[] = []
+ let index = 0
+
+ for (const char of str) {
+ const raw = ignoreCase ? lower(char) : char
+ const chunk = foldAccents ? fold(raw) : raw
+
+ folded += chunk
+
+ // Map per UTF-16 unit — emoji stay two units; some astral CJK compat
+ // ideographs NFD to one BMP unit.
+ for (let unit = 0; unit < chunk.length; unit++) map.push(index)
+
+ index += char.length
+ }
+
+ map.push(str.length)
+
+ return { folded, map }
+}
+
+function collect (haystack: string, needle: string, matchAll: boolean): [number, number][] {
+ const ranges: [number, number][] = []
+ let index = haystack.indexOf(needle)
+
+ while (index !== -1) {
+ ranges.push([index, index + needle.length])
+
+ if (!matchAll) break
+
+ index = haystack.indexOf(needle, index + needle.length)
+ }
+
+ return ranges
+}
+
+// Exclusive end in source space is the start of the next source character
+// after the last consumed folded unit. Ending mid-expansion (s vs ß→ss)
+// therefore spans the whole source character instead of emitting [i, i].
+function remap (map: number[], start: number, end: number): MatchRange {
+ const sourceStart = map[start]!
+ let sourceEnd = map[end]!
+
+ if (end > 0 && map[end] === map[end - 1]) {
+ let index = end
+ const current = map[end]!
+
+ while (index < map.length && map[index] === current) index++
+
+ sourceEnd = map[index]!
+ }
+
+ return [sourceStart, sourceEnd]
+}
+
+function project (ranges: readonly [number, number][], map: number[]): MatchRange[] {
+ const projected: MatchRange[] = []
+
+ for (const [start, end] of ranges) {
+ const span = remap(map, start, end)
+ const last = projected.at(-1)
+
+ if (!last || last[0] !== span[0] || last[1] !== span[1]) projected.push(span)
+ }
+
+ return projected
+}
+
+/**
+ * Finds `[start, end]` index pairs where `query` occurs in `text`, optionally
+ * folding accents on either side. Returned indices always address the original
+ * `text`, even when folding or case-conversion changed its length.
+ *
+ * @param text The string to search.
+ * @param query The term to look for. An empty query yields no ranges.
+ * @param options Optional `ignoreCase`, `ignoreAccents`, `matchAll`.
+ * @returns Ranges into `text`, where `end` is exclusive.
+ *
+ * @example
+ * ```ts
+ * import { findMatchRanges } from '@vuetify/v0'
+ *
+ * findMatchRanges('Zürich', 'zurich', { ignoreCase: true, ignoreAccents: true })
+ * // [[0, 6]]
+ *
+ * findMatchRanges('Łódź', 'Lo', { ignoreAccents: 'target' })
+ * // [[0, 2]]
+ * ```
+ */
+/* #__NO_SIDE_EFFECTS__ */
+export function findMatchRanges (
+ text: string,
+ query: string,
+ options: FindMatchRangesOptions = {},
+): MatchRange[] {
+ const {
+ ignoreCase = false,
+ ignoreAccents = false,
+ matchAll = false,
+ } = options
+
+ const foldQuery = ignoreAccents === true || ignoreAccents === 'query'
+ const foldTarget = ignoreAccents === true || ignoreAccents === 'target'
+
+ const folded = foldQuery ? fold(query) : query
+ const needle = ignoreCase ? lower(folded) : folded
+
+ if (needle.length === 0) {
+ return []
+ }
+
+ if (!foldTarget) {
+ if (!ignoreCase) {
+ return collect(text, needle, matchAll)
+ }
+
+ const lowered = lower(text)
+ if (lowered.length === text.length) {
+ return collect(lowered, needle, matchAll)
+ }
+ }
+
+ const { folded: haystack, map } = foldWithMap(text, ignoreCase, foldTarget)
+
+ return project(collect(haystack, needle, matchAll), map)
+}
diff --git a/packages/0/src/utilities/index.ts b/packages/0/src/utilities/index.ts
index 1bbad61545..beadd72e56 100644
--- a/packages/0/src/utilities/index.ts
+++ b/packages/0/src/utilities/index.ts
@@ -1,6 +1,7 @@
// Utilities
export * from './apca'
export * from './color'
+export * from './diacritics'
export * from './errors'
export * from './helpers'
export * from './instance'