Skip to content
24 changes: 16 additions & 8 deletions apps/docs/src/examples/composables/create-filter/live-search.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { createFilter } from '@vuetify/v0'
import { createFilter, toHighlight } from '@vuetify/v0'
import { computed, shallowRef } from 'vue'

const cities = [
Expand All @@ -21,11 +21,7 @@
const filter = createFilter({ keys: ['name', 'country'] })
const { items } = filter.apply(query, cities)

function highlight (text: string) {
if (!query.value) return text
const regex = new RegExp(`(${query.value})`, 'gi')
return text.replace(regex, '<mark class="bg-warning text-on-warning rounded px-0.5">$1</mark>')
}
const highlightOptions = { ignoreCase: true, matchAll: true }

const hasResults = computed(() => items.value.length > 0)
</script>
Expand Down Expand Up @@ -62,9 +58,21 @@
class="px-4 py-3 flex items-center justify-between hover:bg-surface-tint transition-colors"
>
<div>
<span class="font-medium" v-html="highlight(city.name)" />
<span class="font-medium">
<template v-for="(chunk, i) in toHighlight(city.name, query, highlightOptions)" :key="i">
<mark v-if="chunk.match" class="bg-warning text-on-warning rounded px-0.5">{{ chunk.text }}</mark>
<template v-else>{{ chunk.text }}</template>
</template>
</span>

<span class="mx-2 opacity-30">/</span>
<span class="text-sm opacity-70" v-html="highlight(city.country)" />

<span class="text-sm opacity-70">
<template v-for="(chunk, i) in toHighlight(city.country, query, highlightOptions)" :key="i">
<mark v-if="chunk.match" class="bg-warning text-on-warning rounded px-0.5">{{ chunk.text }}</mark>
<template v-else>{{ chunk.text }}</template>
</template>
</span>
</div>

<span class="text-sm font-mono opacity-50">{{ city.population }}</span>
Expand Down
34 changes: 34 additions & 0 deletions apps/docs/src/examples/composables/to-highlight/basic.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed, shallowRef } from 'vue'
import { toHighlight } from '@vuetify/v0'

const query = shallowRef('lorem')
const text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.'

const chunks = computed(() => toHighlight(text, query, { ignoreCase: true, matchAll: true }))
</script>

<template>
<div class="flex flex-col gap-4 p-6 max-w-xl mx-auto">
<label class="flex flex-col gap-1 text-sm font-medium">
Search
<input
v-model="query"
class="px-3 py-2 rounded border border-divider bg-surface text-on-surface outline-none focus:ring-2 focus:ring-primary/40"
placeholder="Type to highlight…"
type="text"
>
</label>

<p class="leading-relaxed text-on-surface">
<template v-for="(chunk, i) in chunks" :key="i">
<mark
v-if="chunk.match"
class="bg-primary/25 text-on-surface rounded px-0.5 not-italic"
>{{ chunk.text }}</mark>

<template v-else>{{ chunk.text }}</template>
</template>
</p>
</div>
</template>
46 changes: 46 additions & 0 deletions apps/docs/src/examples/composables/to-highlight/match-ranges.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { toHighlight } from '@vuetify/v0'
import type { MatchRange } from '@vuetify/v0'

const text = 'The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs.'

// Pre-computed ranges: highlight every word that starts with a consonant cluster
const matches: MatchRange[] = [
[4, 9], // quick
[10, 15], // brown
[20, 25], // jumps
[31, 35], // lazy
[36, 39], // dog
[41, 45], // Pack
[49, 52], // box
[58, 62], // five
[63, 68], // dozen
]

const chunks = toHighlight(text, undefined, { matches })
</script>

<template>
<div class="flex flex-col gap-4 p-6 max-w-xl mx-auto">
<p class="text-sm text-on-surface/60">
Pre-computed <code>[start, end]</code> ranges — no query needed.
Useful when matches come from a search engine or filter composable.
</p>

<p class="leading-relaxed text-on-surface">
<template v-for="(chunk, i) in chunks" :key="i">
<mark
v-if="chunk.match"
class="bg-success/30 text-on-surface rounded px-0.5 not-italic"
>{{ chunk.text }}</mark>

<template v-else>{{ chunk.text }}</template>
</template>
</p>

<details class="text-xs text-on-surface/60">
<summary class="cursor-pointer select-none font-medium">Show ranges</summary>
<pre class="mt-2 p-2 rounded bg-surface border border-divider overflow-x-auto">{{ JSON.stringify(matches, null, 2) }}</pre>
</details>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed, shallowRef } from 'vue'
import { toHighlight } from '@vuetify/v0'

const input = shallowRef('Vue, reactive')
const query = computed(() =>
input.value.split(',').map(s => s.trim()).filter(Boolean),
)

const text = 'Vue 3 uses a reactive system built on ES Proxy. Reactive state is declared with ref and reactive, and tracked automatically inside computed and watch callbacks.'

const chunks = computed(() => toHighlight(text, query, { ignoreCase: true, matchAll: true }))
</script>

<template>
<div class="flex flex-col gap-4 p-6 max-w-xl mx-auto">
<label class="flex flex-col gap-1 text-sm font-medium">
Queries (comma-separated)
<input
v-model="input"
class="px-3 py-2 rounded border border-divider bg-surface text-on-surface outline-none focus:ring-2 focus:ring-primary/40"
placeholder="e.g. Vue, reactive"
type="text"
>
</label>

<div class="flex flex-wrap gap-1">
<span
v-for="term in query"
:key="term"
class="px-2 py-0.5 rounded-full bg-primary/15 text-on-surface text-xs font-medium"
>{{ term }}</span>
</div>

<p class="leading-relaxed text-on-surface">
<template v-for="(chunk, i) in chunks" :key="i">
<mark
v-if="chunk.match"
class="bg-primary/25 text-on-surface rounded px-0.5 not-italic font-medium"
>{{ chunk.text }}</mark>

<template v-else>{{ chunk.text }}</template>
</template>
</p>
</div>
</template>
1 change: 1 addition & 0 deletions apps/docs/src/pages/composables/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,5 +334,6 @@ Value transformation utilities.
| - | - |
| [toArray](/composables/transformers/to-array) | Convert any value to an array |
| [toElement](/composables/transformers/to-element) | Resolve refs, getters, or component instances to a plain DOM element |
| [toHighlight](/composables/transformers/to-highlight) | Split text into matched and unmatched chunks for query highlighting |
| [toReactive](/composables/transformers/to-reactive) | Convert MaybeRef objects to reactive proxies |

188 changes: 188 additions & 0 deletions apps/docs/src/pages/composables/transformers/to-highlight.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
---
title: toHighlight - Text Search Highlighting for Vue 3
meta:
- name: description
content: Pure Vue 3 transformer that splits text into matched and unmatched chunks given a query string or pre-computed match ranges. No DOM, no state, no reactivity — just a HighlightChunk array. Wrap in computed() for reactive recomputation.
- name: keywords
content: highlight, text search, mark, query, search terms, Vue 3, headless, transformer, filter, autocomplete, MatchRange
features:
category: Composable
label: 'E: toHighlight'
github: /composables/toHighlight/
level: 1
related:
- /composables/data/create-filter
- /components/forms/combobox
- /composables/transformers/to-array
---

# toHighlight

Pure transformer that splits text into matched and unmatched chunks. Returns a plain `HighlightChunk[]` — wrap the call in `computed()` for reactive recomputation.

<DocsPageFeatures :frontmatter />

## Usage

```ts collapse
import { computed } from 'vue'
import { toHighlight } from '@vuetify/v0'

const chunks = computed(() =>
toHighlight(() => props.text, () => props.query, { ignoreCase: true })
)
// chunks.value → [{ text: 'Hello ', match: false }, { text: 'World', match: true }]
```

## Architecture

`toHighlight` resolves its input through a fixed priority order:

```mermaid "Highlight Resolution"
flowchart LR
options[Options] --> matches{matches?}
matches -- non-empty --> normalize[sort + merge ranges]
matches -- empty / none --> query{query?}
query -- truthy --> find[findRanges]
query -- empty / none --> noop[full text, match: false]
find --> ranges{matches found?}
ranges -- yes --> chunk[chunkText]
ranges -- no --> noop
normalize --> chunk
chunk --> chunks[HighlightChunk array]
noop --> chunks
```

## Reactivity

`toHighlight` is a pure transformer — it reads each input through `toValue` once and
returns a plain `HighlightChunk[]`. To make the result track upstream changes, wrap the
call in `computed()` (or any reactive scope). The function itself creates no reactivity.

| Behavior | Reactive | Notes |
| - | :-: | - |
| Calling `toHighlight(text, query)` | <AppErrorIcon /> | One-shot snapshot at call time |
| Wrapping in `computed(() => toHighlight(...))` | <AppSuccessIcon /> | Re-runs when tracked refs change |
| Passing refs or getters as arguments | <AppSuccessIcon /> | `toValue` unwraps them on every call |
| Mutating returned chunks | <AppErrorIcon /> | Treat the array as derived; do not mutate |

> [!TIP] Reach for plain values, refs, or getters
> Every input accepts `MaybeRefOrGetter<T>`. Pass a literal for static input, a `Ref` for
> v-model integration, or a getter (`() => props.text`) for prop-driven reactivity. Wrap
> the call in `computed()` when you want the result to update automatically.

## Examples

::: example
/composables/to-highlight/basic

### Search input

Live query against a paragraph. The example wraps `toHighlight` in `computed()` so the
chunks update instantly when the `query` ref changes — swap the search term and the
markup re-renders without any manual wiring. Each `HighlightChunk` carries
`{ text, match }`, so you control the full rendering: use a native `<mark>` for semantics
and screen-reader compatibility, a `<strong>` for bold-only, or whatever your design
calls for.

Matching is case-sensitive by default. Set `ignoreCase: true` to
match regardless of casing in the source text.

:::

::: example
/composables/to-highlight/multiple-queries

### Multiple queries

Pass an array to `query` to highlight several terms at once. Overlapping or adjacent
spans are merged into a single highlight — `['foo', 'oba']` against `'foobar'` produces
one chunk `{ text: 'fooba', match: true }` rather than two. This matches how most
search engines report matches and avoids nested or duplicated highlights.

The example splits a comma-separated input into an array via `computed`. You can also
derive the array from a tag list, token stream, or search-engine suggestion list — anything
that produces `string[]`.

When `matchAll` is `false`, only the first occurrence of each term is highlighted. Useful
for "jump to first match" UI patterns where highlighting every hit would be distracting.

:::

::: example
/composables/to-highlight/match-ranges

### Pre-computed ranges

Skip the query entirely and supply exact `[start, end]` index pairs via `matches`.
When `matches` is a non-empty array it takes priority over `query`, and `matchAll`
is ignored — the caller is asserting full control over which spans to highlight.

Pre-computed ranges are useful when:

- A full-text search engine returns character offsets directly alongside results.
- You're combining `createFilter` from `@vuetify/v0` with its forthcoming `matches` output
— a single filter pass yields both the filtered items *and* their highlight spans, so you
don't run the query algorithm twice.
- You need to highlight structurally identified tokens (syntax spans, named entities,
diff hunks) rather than substring matches.

The `MatchRange` type is `[number, number]` — a `[start, end]` pair where `end` is
exclusive (matching JavaScript's `String.prototype.slice` convention).

| Priority | Source | Condition |
|----------|--------|-----------|
| 1 | `matches` | non-empty array |
| 2 | `query` | string or string[] |
| 3 | No-match fallback | neither provided |

:::

## Accessibility

Wrap matched chunks in the native `<mark>` element. It carries the implicit ARIA role
`mark` and is announced by screen readers as highlighted or marked text. No additional
ARIA attributes are needed on the wrapper element.

> [!TIP]
> WCAG 1.4.3 (Contrast — Minimum) applies to highlighted text. Ensure sufficient contrast
> between the mark background color and the surrounding text.

## Questions

::: faq
??? Does toHighlight preserve the original casing?

Yes. The source `text` string is sliced at match boundaries, so the original characters
(including casing, punctuation, and whitespace) are always preserved in the output chunks.
`ignoreCase` affects only the matching logic, not the returned text.

??? Can I use it with createFilter results?

Yes. The `matches` option accepts `MatchRange[]` — `[start, end]` pairs. Once
`createFilter` exposes positional data, pass the result directly and skip the query path.

??? How does it handle overlapping multi-term matches?

Overlapping or adjacent spans are merged before the chunks array is produced.
`['foo', 'oba']` against `'foobar'` yields `[{ text: 'fooba', match: true }, { text: 'r', match: false }]`
rather than two separate matches.

??? Are caller-supplied match ranges normalized?

Yes. Ranges passed via the `matches` option are sorted by start index and merged on
overlap or adjacency before chunking. Pass `[[4, 6], [0, 2]]` or `[[0, 4], [2, 6]]` and
the output is the same as if you had supplied the canonical sorted, non-overlapping form.

??? What happens when neither query nor matches is provided?

The function returns a single `[{ text: sourceText, match: false }]` chunk — the full
string with no highlights. Safe to iterate without any guard.

??? Is it SSR-safe?

Yes. `toHighlight` is a pure function with no DOM access and no reactive state. It is
safe to call during SSR.
:::

<DocsApi />
13 changes: 13 additions & 0 deletions apps/docs/src/typed-router.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/composables/transformers/to-highlight': RouteRecordInfo<
'/composables/transformers/to-highlight',
'/composables/transformers/to-highlight',
Record<never, never>,
Record<never, never>,
| never
>,
'/composables/transformers/to-reactive': RouteRecordInfo<
'/composables/transformers/to-reactive',
'/composables/transformers/to-reactive',
Expand Down Expand Up @@ -1739,6 +1746,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/composables/transformers/to-highlight.md': {
routes:
| '/composables/transformers/to-highlight'
views:
| never
}
'src/pages/composables/transformers/to-reactive.md': {
routes:
| '/composables/transformers/to-reactive'
Expand Down
Loading
Loading