Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/highlight-ignore-accents.md
Original file line number Diff line number Diff line change
@@ -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.
79 changes: 79 additions & 0 deletions apps/docs/src/examples/composables/to-highlight/accents.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { Input, Single, toHighlight } from '@vuetify/v0'
import { computed, shallowRef, toRef } from 'vue'
import type { IgnoreAccents } from '@vuetify/v0'

const query = shallowRef('zurich')
const mode = shallowRef<'off' | 'target' | 'query' | 'both'>('target')

const hints = {
off: 'Diacritics must match; case is still ignored.',
target: 'Accents in the text are folded — type “zurich” or “krakow”.',
query: 'Accents in the query are folded — paste “café” to match “cafe”.',
both: 'Either side may carry accents.',
}

const ignoreAccents = toRef((): IgnoreAccents => {
if (mode.value === 'off') return false
if (mode.value === 'both') return true

return mode.value
})

const cities = [
'São Paulo',
'Zürich',
'Kraków',
'Málaga',
'Bogotá',
'Tromsø',
'cafe',
'Montreal',
'Reykjavík',
]

const rows = computed(() => cities.map(city => ({
city,
chunks: toHighlight(city, query, { ignoreCase: true, matchAll: true, ignoreAccents }),
})))
</script>

<template>
<div class="flex flex-col gap-3 p-4 max-w-md mx-auto">
<Input.Root id="accent-search" v-model="query" label="Search cities">
<Input.Control
class="w-full px-3 py-2 rounded-lg border border-divider bg-surface text-on-surface placeholder:text-on-surface/40 outline-none data-[focused]:border-primary transition-colors"
placeholder="Type an unaccented query…"
/>
</Input.Root>

<Single.Root v-model="mode" mandatory>
<div class="flex flex-wrap gap-2">
<Single.Item
v-for="value in (['off', 'target', 'query', 'both'] as const)"
:key="value"
v-slot="{ attrs }"
:value
>
<button
v-bind="attrs"
class="px-2.5 py-1 rounded-full border border-divider text-xs font-medium text-on-surface/70 transition-colors data-[selected]:border-primary data-[selected]:bg-primary/15 data-[selected]:text-on-surface"
>
{{ value }}
</button>
</Single.Item>
</div>
</Single.Root>

<p class="text-xs text-on-surface/60">{{ hints[mode] }}</p>

<ul class="flex flex-col rounded-lg border border-divider bg-surface divide-y divide-divider">
<li v-for="row in rows" :key="row.city" class="px-3 py-2 text-sm">
<template v-for="(chunk, index) in row.chunks" :key="index">
<mark v-if="chunk.match" class="rounded-sm bg-primary/25 px-0.5 text-on-surface">{{ chunk.text }}</mark>
<template v-else>{{ chunk.text }}</template>
</template>
</li>
</ul>
</div>
</template>
27 changes: 27 additions & 0 deletions apps/docs/src/pages/composables/transformers/to-highlight.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<mark>` element. It carries the implicit ARIA role
Expand All @@ -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?

Expand Down
21 changes: 20 additions & 1 deletion apps/docs/src/pages/guide/features/utilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -534,4 +554,3 @@ const id = useId() // 'v:0' (Vue's format)
// Outside component
const id = useId() // 'v0-0', 'v0-1', ...
```

7 changes: 6 additions & 1 deletion dev/src/composables.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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')
}
Expand Down Expand Up @@ -753,6 +757,7 @@ declare module 'vue' {
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly extractLeaves: UnwrapRef<typeof import('../../packages/0/src/composables/createDataTable/index')['extractLeaves']>
readonly findMatchRanges: UnwrapRef<typeof import('../../packages/0/src/utilities/diacritics')['findMatchRanges']>
readonly flatten: UnwrapRef<typeof import('../../packages/0/src/composables/createTokens/index')['flatten']>
readonly foreground: UnwrapRef<typeof import('../../packages/0/src/utilities/apca')['foreground']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
Expand Down
70 changes: 70 additions & 0 deletions packages/0/src/composables/toHighlight/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
59 changes: 23 additions & 36 deletions packages/0/src/composables/toHighlight/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -75,17 +66,22 @@ export interface ToHighlightOptions {
* Caller-supplied ranges are sorted and merged before chunking, so
* unsorted or overlapping input is handled gracefully.
*/
matches?: MaybeRefOrGetter<readonly MatchRange[] | undefined>
matches?: MaybeRefOrGetter<readonly Range[] | undefined>
/**
* Highlight every occurrence (`true`) or only the first per term (`false`, default).
* Ignored when `matches` is provided.
*/
matchAll?: MaybeRefOrGetter<boolean>
/** Case-insensitive matching. Default `false`. */
ignoreCase?: MaybeRefOrGetter<boolean>
/**
* Folds accents before matching. `'query'` folds only the search term,
* `'target'` only the text, `true` both sides. Default `false`.
*/
ignoreAccents?: MaybeRefOrGetter<IgnoreAccents>
}

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])
Expand All @@ -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

Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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 }]
}

Expand Down
Loading
Loading