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 @@ + + + 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'