From d3d30f19c213c8e6d9237728013254d50a7fb0fc Mon Sep 17 00:00:00 2001
From: J-Sek
Date: Wed, 29 Apr 2026 18:44:21 +0200
Subject: [PATCH 1/9] feat(toHighlight): add new utility
---
.../composables/to-highlight/basic.vue | 33 ++++
.../composables/to-highlight/match-ranges.vue | 45 ++++++
.../to-highlight/multiple-queries.vue | 45 ++++++
apps/docs/src/pages/composables/index.md | 1 +
.../composables/transformers/to-highlight.md | 152 ++++++++++++++++++
apps/docs/src/typed-router.d.ts | 13 ++
dev/src/composables.d.ts | 1 -
packages/0/src/composables/index.ts | 1 +
.../src/composables/toHighlight/index.test.ts | 134 +++++++++++++++
.../0/src/composables/toHighlight/index.ts | 109 +++++++++++++
packages/0/src/maturity.json | 5 +
11 files changed, 538 insertions(+), 1 deletion(-)
create mode 100644 apps/docs/src/examples/composables/to-highlight/basic.vue
create mode 100644 apps/docs/src/examples/composables/to-highlight/match-ranges.vue
create mode 100644 apps/docs/src/examples/composables/to-highlight/multiple-queries.vue
create mode 100644 apps/docs/src/pages/composables/transformers/to-highlight.md
create mode 100644 packages/0/src/composables/toHighlight/index.test.ts
create mode 100644 packages/0/src/composables/toHighlight/index.ts
diff --git a/apps/docs/src/examples/composables/to-highlight/basic.vue b/apps/docs/src/examples/composables/to-highlight/basic.vue
new file mode 100644
index 000000000..ed71c5af4
--- /dev/null
+++ b/apps/docs/src/examples/composables/to-highlight/basic.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+ {{ chunk.text }}
+ {{ chunk.text }}
+
+
+
+
diff --git a/apps/docs/src/examples/composables/to-highlight/match-ranges.vue b/apps/docs/src/examples/composables/to-highlight/match-ranges.vue
new file mode 100644
index 000000000..6ef029a96
--- /dev/null
+++ b/apps/docs/src/examples/composables/to-highlight/match-ranges.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+ Pre-computed [start, end] ranges — no query needed.
+ Useful when matches come from a search engine or filter composable.
+
+
+
+
+ {{ chunk.text }}
+ {{ chunk.text }}
+
+
+
+
+ Show ranges
+ {{ JSON.stringify(matches, null, 2) }}
+
+
+
diff --git a/apps/docs/src/examples/composables/to-highlight/multiple-queries.vue b/apps/docs/src/examples/composables/to-highlight/multiple-queries.vue
new file mode 100644
index 000000000..80fee03b8
--- /dev/null
+++ b/apps/docs/src/examples/composables/to-highlight/multiple-queries.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+ {{ term }}
+
+
+
+
+ {{ chunk.text }}
+ {{ chunk.text }}
+
+
+
+
diff --git a/apps/docs/src/pages/composables/index.md b/apps/docs/src/pages/composables/index.md
index ff791bdbe..5980f93f9 100644
--- a/apps/docs/src/pages/composables/index.md
+++ b/apps/docs/src/pages/composables/index.md
@@ -328,5 +328,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 |
diff --git a/apps/docs/src/pages/composables/transformers/to-highlight.md b/apps/docs/src/pages/composables/transformers/to-highlight.md
new file mode 100644
index 000000000..0d0638594
--- /dev/null
+++ b/apps/docs/src/pages/composables/transformers/to-highlight.md
@@ -0,0 +1,152 @@
+---
+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 — just a ComputedRef.
+ - 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 `ComputedRef` — render it however you like.
+
+
+
+## Usage
+
+```ts collapse
+import { toHighlight } from '@vuetify/v0'
+
+const chunks = toHighlight({
+ text: () => props.text,
+ query: () => props.query,
+ ignoreCase: true,
+})
+// chunks.value → [{ text: 'Hello ', match: false }, { text: 'World', match: true }]
+```
+
+## Examples
+
+::: example
+/composables/to-highlight/basic
+
+### Search input
+
+Live query against a paragraph. `toHighlight` returns a `ComputedRef` that recomputes
+whenever any reactive input changes — swap the `query` ref and the chunks update instantly
+without any manual wiring. Each `HighlightChunk` carries `{ text, match }`, so you control
+the full rendering: use a native `` for semantics and screen-reader compatibility,
+a `` for bold-only, or whatever your design calls for.
+
+Matching is case-insensitive by default (`ignoreCase: true`). Set `ignoreCase: false` to
+respect the exact casing in the source text.
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `text` | `MaybeRefOrGetter` | — | Source string to split |
+| `query` | `MaybeRefOrGetter` | `undefined` | Search term(s) |
+| `ignoreCase` | `MaybeRefOrGetter` | `true` | Case-insensitive matching |
+| `matchAll` | `MaybeRefOrGetter` | `true` | Highlight every occurrence vs first only |
+
+:::
+
+::: 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 `` 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.
+
+??? What happens when neither query nor matches is provided?
+
+The returned `ComputedRef` resolves to 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 computed value with no DOM access. It is safe to call
+during SSR.
+:::
+
+
diff --git a/apps/docs/src/typed-router.d.ts b/apps/docs/src/typed-router.d.ts
index 9d1a441ca..86318e043 100644
--- a/apps/docs/src/typed-router.d.ts
+++ b/apps/docs/src/typed-router.d.ts
@@ -762,6 +762,13 @@ declare module 'vue-router/auto-routes' {
Record,
| never
>,
+ '/composables/transformers/to-highlight': RouteRecordInfo<
+ '/composables/transformers/to-highlight',
+ '/composables/transformers/to-highlight',
+ Record,
+ Record,
+ | never
+ >,
'/composables/transformers/to-reactive': RouteRecordInfo<
'/composables/transformers/to-reactive',
'/composables/transformers/to-reactive',
@@ -1664,6 +1671,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'
diff --git a/dev/src/composables.d.ts b/dev/src/composables.d.ts
index 674e2484a..3edddce13 100644
--- a/dev/src/composables.d.ts
+++ b/dev/src/composables.d.ts
@@ -522,7 +522,6 @@ import { UnwrapRef } from 'vue'
declare module 'vue' {
interface GlobalComponents {}
interface ComponentCustomProperties {
- readonly COMMON_ELEMENTS: UnwrapRef
readonly ClientAdapter: UnwrapRef
readonly ComboboxClientAdapter: UnwrapRef
readonly ComboboxServerAdapter: UnwrapRef
diff --git a/packages/0/src/composables/index.ts b/packages/0/src/composables/index.ts
index 7cb160be0..ec63f1fdb 100644
--- a/packages/0/src/composables/index.ts
+++ b/packages/0/src/composables/index.ts
@@ -29,6 +29,7 @@ export * from './createValidation'
export * from './createVirtual'
export * from './toArray'
export * from './toElement'
+export * from './toHighlight'
export * from './toReactive'
export * from './useBreakpoints'
export * from './useClickOutside'
diff --git a/packages/0/src/composables/toHighlight/index.test.ts b/packages/0/src/composables/toHighlight/index.test.ts
new file mode 100644
index 000000000..af55e2180
--- /dev/null
+++ b/packages/0/src/composables/toHighlight/index.test.ts
@@ -0,0 +1,134 @@
+import { describe, expect, it } from 'vitest'
+
+// Utilities
+import { shallowRef } from 'vue'
+
+import { toHighlight } from './index'
+
+function run (options: Parameters[0]) {
+ return toHighlight(options).value
+}
+
+describe('toHighlight', () => {
+ describe('pre-computed matches', () => {
+ it('should not emit a leading empty span when match starts at position 0', () => {
+ expect(run({ text: 'foobar', matches: [[0, 3]] })).toStrictEqual([
+ { text: 'foo', match: true },
+ { text: 'bar', match: false },
+ ])
+ })
+
+ it('should not emit a trailing empty span when match ends at text length', () => {
+ expect(run({ text: 'foobar', matches: [[3, 6]] })).toStrictEqual([
+ { text: 'foo', match: false },
+ { text: 'bar', match: true },
+ ])
+ })
+
+ it('should handle a mid-text match with surrounding non-match chunks', () => {
+ expect(run({ text: 'foobar', matches: [[2, 4]] })).toStrictEqual([
+ { text: 'fo', match: false },
+ { text: 'ob', match: true },
+ { text: 'ar', match: false },
+ ])
+ })
+
+ it('should handle multiple non-overlapping matches', () => {
+ expect(run({ text: 'foobar', matches: [[0, 2], [4, 6]] })).toStrictEqual([
+ { text: 'fo', match: true },
+ { text: 'ob', match: false },
+ { text: 'ar', match: true },
+ ])
+ })
+ })
+
+ describe('query string', () => {
+ it('should match case-insensitively by default', () => {
+ expect(run({ text: 'Hello World', query: 'HELLO' })[0]).toStrictEqual({ text: 'Hello', match: true })
+ })
+
+ it('should match case-sensitively when ignoreCase is false', () => {
+ expect(run({ text: 'Hello World', query: 'HELLO', ignoreCase: false })).toStrictEqual([
+ { text: 'Hello World', match: false },
+ ])
+ })
+
+ it('should find every occurrence by default (matchAll: true)', () => {
+ expect(run({ text: 'aa bb aa', query: 'aa' }).filter(c => c.match).map(c => c.text))
+ .toStrictEqual(['aa', 'aa'])
+ })
+
+ it('should find only the first occurrence when matchAll is false', () => {
+ expect(run({ text: 'aa bb aa', query: 'aa', matchAll: false }).filter(c => c.match).map(c => c.text))
+ .toStrictEqual(['aa'])
+ })
+
+ it('should return a single no-match chunk when query has no match', () => {
+ expect(run({ text: 'hello', query: 'xyz' })).toStrictEqual([{ text: 'hello', match: false }])
+ })
+
+ it('should merge overlapping spans from multiple queries', () => {
+ expect(run({ text: 'foobar', query: ['foo', 'oba'] })).toStrictEqual([
+ { text: 'fooba', match: true },
+ { text: 'r', match: false },
+ ])
+ })
+
+ it('should merge adjacent spans', () => {
+ expect(run({ text: 'foobar', query: ['foo', 'bar'] })).toStrictEqual([
+ { text: 'foobar', match: true },
+ ])
+ })
+
+ it('should ignore empty strings in a query array', () => {
+ expect(run({ text: 'hello', query: ['', 'ell'] })).toStrictEqual([
+ { text: 'h', match: false },
+ { text: 'ell', match: true },
+ { text: 'o', match: false },
+ ])
+ })
+ })
+
+ describe('priority and fallthrough', () => {
+ it('should use pre-computed matches over query when both are provided', () => {
+ expect(run({ text: 'hello', matches: [[1, 3]], query: 'hello' })).toStrictEqual([
+ { text: 'h', match: false },
+ { text: 'el', match: true },
+ { text: 'lo', match: false },
+ ])
+ })
+
+ it('should fall through to query when matches is an empty array', () => {
+ expect(run({ text: 'hello', matches: [], query: 'ell' })).toStrictEqual([
+ { text: 'h', match: false },
+ { text: 'ell', match: true },
+ { text: 'o', match: false },
+ ])
+ })
+
+ it('should return a single no-match chunk when neither query nor matches', () => {
+ expect(run({ text: 'hello' })).toStrictEqual([{ text: 'hello', match: false }])
+ })
+
+ it('should ignore matchAll when matches is provided', () => {
+ // matchAll: false should NOT truncate pre-computed matches
+ expect(run({ text: 'aabbaa', matches: [[0, 2], [4, 6]], matchAll: false })).toStrictEqual([
+ { text: 'aa', match: true },
+ { text: 'bb', match: false },
+ { text: 'aa', match: true },
+ ])
+ })
+ })
+
+ describe('reactive inputs', () => {
+ it('should recompute when reactive text changes', () => {
+ const text = shallowRef('hello world')
+ const chunks = toHighlight({ text, query: 'world' })
+
+ expect(chunks.value[1]).toStrictEqual({ text: 'world', match: true })
+
+ text.value = 'goodbye world'
+ expect(chunks.value[0]).toStrictEqual({ text: 'goodbye ', match: false })
+ })
+ })
+})
diff --git a/packages/0/src/composables/toHighlight/index.ts b/packages/0/src/composables/toHighlight/index.ts
new file mode 100644
index 000000000..811335004
--- /dev/null
+++ b/packages/0/src/composables/toHighlight/index.ts
@@ -0,0 +1,109 @@
+/**
+ * @module toHighlight
+ *
+ * @see https://0.vuetifyjs.com/composables/transformers/to-highlight
+ *
+ * @remarks
+ * Pure transformer — no DOM, no state, no registry. Splits text into matched and
+ * unmatched chunks given a query string, an array of query strings, or pre-computed
+ * `[start, end]` match ranges (e.g., from createFilter).
+ */
+
+// Utilities
+import { computed, toValue } from 'vue'
+
+// Types
+import type { ComputedRef, MaybeRefOrGetter } from 'vue'
+
+export type MatchRange = [number, number]
+
+export interface HighlightChunk {
+ text: string
+ match: boolean
+}
+
+export interface ToHighlightOptions {
+ /** The source string to split into chunks. */
+ text: MaybeRefOrGetter
+ /** One or more search terms. Case sensitivity controlled by `ignoreCase`. */
+ query?: MaybeRefOrGetter
+ /**
+ * Pre-computed `[start, end]` index pairs.
+ * When non-empty, takes priority over `query`. `matchAll` is ignored.
+ */
+ matches?: MaybeRefOrGetter
+ /**
+ * Highlight every occurrence (`true`, default) or only the first per term (`false`).
+ * Ignored when `matches` is provided.
+ */
+ matchAll?: MaybeRefOrGetter
+ /** Case-insensitive matching. Default `true`. */
+ ignoreCase?: MaybeRefOrGetter
+}
+
+function chunkText (text: string, ranges: MatchRange[]): HighlightChunk[] {
+ const chunks: HighlightChunk[] = []
+ let cursor = 0
+
+ for (const [start, end] of ranges) {
+ if (cursor < start) chunks.push({ text: text.slice(cursor, start), match: false })
+ chunks.push({ text: text.slice(start, end), match: true })
+ cursor = end
+ }
+
+ if (cursor < text.length) chunks.push({ text: text.slice(cursor), match: false })
+
+ return chunks
+}
+
+function findRanges (text: string, query: string | string[], matchAll: boolean, ignoreCase: boolean): MatchRange[] {
+ const terms = (Array.isArray(query) ? query : [query]).filter(Boolean)
+ const haystack = ignoreCase ? text.toLocaleLowerCase() : text
+ const spans: MatchRange[] = []
+
+ for (const term of terms) {
+ const needle = ignoreCase ? term.toLocaleLowerCase() : term
+ let i = haystack.indexOf(needle)
+
+ if (i !== -1) {
+ spans.push([i, i + term.length])
+ if (matchAll) {
+ i = haystack.indexOf(needle, i + term.length)
+ while (i !== -1) {
+ spans.push([i, i + term.length])
+ i = haystack.indexOf(needle, i + term.length)
+ }
+ }
+ }
+ }
+
+ spans.sort((a, b) => a[0] - b[0])
+
+ const merged: MatchRange[] = []
+ for (const span of spans) {
+ const last = merged.at(-1)
+ if (last && span[0] <= last[1]) last[1] = Math.max(last[1], span[1])
+ else merged.push([...span])
+ }
+
+ return merged
+}
+
+export function toHighlight (options: ToHighlightOptions): ComputedRef {
+ return computed(() => {
+ const text = toValue(options.text)
+ const matches = toValue(options.matches)
+ const query = toValue(options.query)
+ const matchAll = toValue(options.matchAll) ?? true
+ const ignoreCase = toValue(options.ignoreCase) ?? true
+
+ if (matches?.length) return chunkText(text, matches)
+
+ if (query) {
+ const ranges = findRanges(text, query, matchAll, ignoreCase)
+ return ranges.length > 0 ? chunkText(text, ranges) : [{ text, match: false }]
+ }
+
+ return [{ text, match: false }]
+ })
+}
diff --git a/packages/0/src/maturity.json b/packages/0/src/maturity.json
index 8a79db1f5..7b5f9b57c 100644
--- a/packages/0/src/maturity.json
+++ b/packages/0/src/maturity.json
@@ -255,6 +255,11 @@
"since": "0.1.0",
"category": "transformers"
},
+ "toHighlight": {
+ "level": "preview",
+ "since": "0.3.0",
+ "category": "transformers"
+ },
"toReactive": {
"level": "preview",
"since": "0.1.0",
From 16bffc9a2dff8a8c14a3d9f6118dd6e5e4e577c4 Mon Sep 17 00:00:00 2001
From: John Leider
Date: Wed, 29 Apr 2026 13:34:12 -0500
Subject: [PATCH 2/9] refactor(toHighlight): defensive range normalization and
full docs
mergeRanges now sorts, merges, and drops invalid (zero-width or inverted)
ranges before chunking, applied to both the query and caller-supplied
matches paths so unsorted or overlapping input produces correct output.
Adds per-symbol @example JSDoc, the tree-shaking side-effects marker,
Architecture mermaid + Reactivity section in the docs page, README sync,
and tests covering unsorted, overlapping, zero-width, inverted, and
out-of-bounds ranges plus reactive query/matches and getter inputs.
---
.../composables/to-highlight/basic.vue | 7 +-
.../composables/to-highlight/match-ranges.vue | 3 +-
.../to-highlight/multiple-queries.vue | 9 +-
.../composables/transformers/to-highlight.md | 49 ++++++--
packages/0/README.md | 1 +
.../src/composables/toHighlight/index.test.ts | 84 ++++++++++++-
.../0/src/composables/toHighlight/index.ts | 115 +++++++++++++++---
packages/0/src/maturity.json | 2 +-
8 files changed, 235 insertions(+), 35 deletions(-)
diff --git a/apps/docs/src/examples/composables/to-highlight/basic.vue b/apps/docs/src/examples/composables/to-highlight/basic.vue
index ed71c5af4..af92e4402 100644
--- a/apps/docs/src/examples/composables/to-highlight/basic.vue
+++ b/apps/docs/src/examples/composables/to-highlight/basic.vue
@@ -14,10 +14,10 @@
Search
+ placeholder="Type to highlight…"
+ type="text"
+ >
@@ -26,6 +26,7 @@
v-if="chunk.match"
class="bg-primary/25 text-on-surface rounded px-0.5 not-italic"
>{{ chunk.text }}
+
{{ chunk.text }}
diff --git a/apps/docs/src/examples/composables/to-highlight/match-ranges.vue b/apps/docs/src/examples/composables/to-highlight/match-ranges.vue
index 6ef029a96..166ec2246 100644
--- a/apps/docs/src/examples/composables/to-highlight/match-ranges.vue
+++ b/apps/docs/src/examples/composables/to-highlight/match-ranges.vue
@@ -6,7 +6,7 @@
// Pre-computed ranges: highlight every word that starts with a consonant cluster
const matches: MatchRange[] = [
- [4, 9], // quick
+ [4, 9], // quick
[10, 15], // brown
[20, 25], // jumps
[31, 35], // lazy
@@ -33,6 +33,7 @@
v-if="chunk.match"
class="bg-success/30 text-on-surface rounded px-0.5 not-italic"
>{{ chunk.text }}
+
{{ chunk.text }}
diff --git a/apps/docs/src/examples/composables/to-highlight/multiple-queries.vue b/apps/docs/src/examples/composables/to-highlight/multiple-queries.vue
index 80fee03b8..81f2fb2e6 100644
--- a/apps/docs/src/examples/composables/to-highlight/multiple-queries.vue
+++ b/apps/docs/src/examples/composables/to-highlight/multiple-queries.vue
@@ -4,7 +4,7 @@
const input = ref('Vue, reactive')
const query = computed(() =>
- input.value.split(',').map(s => s.trim()).filter(Boolean)
+ 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.'
@@ -18,10 +18,10 @@
Queries (comma-separated)
+ placeholder="e.g. Vue, reactive"
+ type="text"
+ >
@@ -38,6 +38,7 @@
v-if="chunk.match"
class="bg-primary/25 text-on-surface rounded px-0.5 not-italic font-medium"
>{{ 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 0d0638594..00318e176 100644
--- a/apps/docs/src/pages/composables/transformers/to-highlight.md
+++ b/apps/docs/src/pages/composables/transformers/to-highlight.md
@@ -35,6 +35,42 @@ const chunks = toHighlight({
// 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` returns a **`ComputedRef
`**. The chunks array recomputes
+whenever any of its `MaybeRefOrGetter` inputs change — `text`, `query`, `matches`, `matchAll`,
+`ignoreCase` are all read through `toValue`, so refs and getters track automatically.
+
+| Behavior | Reactive | Notes |
+| - | :-: | - |
+| Reading `chunks.value` | | Standard `ComputedRef` access |
+| Mutating `text`, `query`, `matches` | | Refs and getters tracked through `toValue` |
+| Toggling `ignoreCase` or `matchAll` | | Same `MaybeRefOrGetter` contract |
+| Mutating returned chunks | | Treat the array as derived; do not mutate |
+
+> [!TIP] Reach for plain values, refs, or getters
+> Every option accepts `MaybeRefOrGetter`. Pass a literal for static input, a `Ref` for
+> v-model integration, or a getter (`() => props.text`) for prop-driven reactivity.
+
## Examples
::: example
@@ -51,13 +87,6 @@ a `` for bold-only, or whatever your design calls for.
Matching is case-insensitive by default (`ignoreCase: true`). Set `ignoreCase: false` to
respect the exact casing in the source text.
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `text` | `MaybeRefOrGetter` | — | Source string to split |
-| `query` | `MaybeRefOrGetter` | `undefined` | Search term(s) |
-| `ignoreCase` | `MaybeRefOrGetter` | `true` | Case-insensitive matching |
-| `matchAll` | `MaybeRefOrGetter` | `true` | Highlight every occurrence vs first only |
-
:::
::: example
@@ -138,6 +167,12 @@ 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 returned `ComputedRef` resolves to a single `[{ text: sourceText, match: false }]`
diff --git a/packages/0/README.md b/packages/0/README.md
index d6736ad84..406d449b3 100644
--- a/packages/0/README.md
+++ b/packages/0/README.md
@@ -205,6 +205,7 @@ Selection management composables built on `createRegistry`:
- [`toArray`](https://0.vuetifyjs.com/composables/transformers/to-array) - Array transformation utilities
- [`toElement`](https://0.vuetifyjs.com/composables/transformers/to-element) - Normalize refs, selectors, and elements to DOM elements
+- [`toHighlight`](https://0.vuetifyjs.com/composables/transformers/to-highlight) - Split text into matched and unmatched chunks for query highlighting
- [`toReactive`](https://0.vuetifyjs.com/composables/transformers/to-reactive) - Reactive object conversion
#### System
diff --git a/packages/0/src/composables/toHighlight/index.test.ts b/packages/0/src/composables/toHighlight/index.test.ts
index af55e2180..0bd0f3402 100644
--- a/packages/0/src/composables/toHighlight/index.test.ts
+++ b/packages/0/src/composables/toHighlight/index.test.ts
@@ -40,6 +40,52 @@ describe('toHighlight', () => {
{ text: 'ar', match: true },
])
})
+
+ it('should sort caller-supplied matches that arrive out of order', () => {
+ expect(run({ text: 'foobar', matches: [[4, 6], [0, 2]] })).toStrictEqual([
+ { text: 'fo', match: true },
+ { text: 'ob', match: false },
+ { text: 'ar', match: true },
+ ])
+ })
+
+ it('should merge caller-supplied matches that overlap', () => {
+ expect(run({ text: 'foobar', matches: [[0, 4], [2, 6]] })).toStrictEqual([
+ { text: 'foobar', match: true },
+ ])
+ })
+
+ it('should merge caller-supplied matches that are adjacent', () => {
+ expect(run({ text: 'foobar', matches: [[0, 3], [3, 6]] })).toStrictEqual([
+ { text: 'foobar', match: true },
+ ])
+ })
+
+ it('should not mutate the caller-supplied matches array or its tuples', () => {
+ const ranges: [number, number][] = [[0, 4], [2, 6]]
+ const snapshot = ranges.map(r => [...r])
+ run({ text: 'foobar', matches: ranges })
+ expect(ranges.map(r => [...r])).toStrictEqual(snapshot)
+ })
+
+ it('should drop inverted ranges where start >= end', () => {
+ expect(run({ text: 'foobar', matches: [[5, 3]] })).toStrictEqual([
+ { text: 'foobar', match: false },
+ ])
+ })
+
+ it('should drop zero-width ranges', () => {
+ expect(run({ text: 'foobar', matches: [[3, 3], [0, 2]] })).toStrictEqual([
+ { text: 'fo', match: true },
+ { text: 'obar', match: false },
+ ])
+ })
+
+ it('should clamp out-of-bounds end indices via String.slice semantics', () => {
+ expect(run({ text: 'hi', matches: [[0, 999]] })).toStrictEqual([
+ { text: 'hi', match: true },
+ ])
+ })
})
describe('query string', () => {
@@ -111,7 +157,6 @@ describe('toHighlight', () => {
})
it('should ignore matchAll when matches is provided', () => {
- // matchAll: false should NOT truncate pre-computed matches
expect(run({ text: 'aabbaa', matches: [[0, 2], [4, 6]], matchAll: false })).toStrictEqual([
{ text: 'aa', match: true },
{ text: 'bb', match: false },
@@ -130,5 +175,42 @@ describe('toHighlight', () => {
text.value = 'goodbye world'
expect(chunks.value[0]).toStrictEqual({ text: 'goodbye ', match: false })
})
+
+ it('should recompute when reactive query changes', () => {
+ const query = shallowRef('hello')
+ const chunks = toHighlight({ text: 'hello world', query })
+
+ expect(chunks.value[0]).toStrictEqual({ text: 'hello', match: true })
+
+ query.value = 'world'
+ expect(chunks.value[1]).toStrictEqual({ text: 'world', match: true })
+ })
+
+ it('should recompute when reactive matches change', () => {
+ const matches = shallowRef<[number, number][]>([[0, 5]])
+ const chunks = toHighlight({ text: 'hello world', matches })
+
+ expect(chunks.value[0]).toStrictEqual({ text: 'hello', match: true })
+
+ matches.value = [[6, 11]]
+ expect(chunks.value[1]).toStrictEqual({ text: 'world', match: true })
+ })
+
+ it('should accept getter functions for any input', () => {
+ const source = shallowRef('hello world')
+ const term = shallowRef('world')
+ const chunks = toHighlight({
+ text: () => source.value,
+ query: () => term.value,
+ ignoreCase: () => true,
+ matchAll: () => true,
+ })
+
+ expect(chunks.value[1]).toStrictEqual({ text: 'world', match: true })
+
+ source.value = 'goodbye world'
+ term.value = 'goodbye'
+ expect(chunks.value[0]).toStrictEqual({ text: 'goodbye', match: true })
+ })
})
})
diff --git a/packages/0/src/composables/toHighlight/index.ts b/packages/0/src/composables/toHighlight/index.ts
index 811335004..50ae2695b 100644
--- a/packages/0/src/composables/toHighlight/index.ts
+++ b/packages/0/src/composables/toHighlight/index.ts
@@ -7,6 +7,18 @@
* Pure transformer — no DOM, no state, no registry. Splits text into matched and
* unmatched chunks given a query string, an array of query strings, or pre-computed
* `[start, end]` match ranges (e.g., from createFilter).
+ *
+ * @example
+ * ```ts
+ * import { toHighlight } from '@vuetify/v0'
+ *
+ * const chunks = toHighlight({
+ * text: 'Hello World',
+ * query: 'world',
+ * })
+ * console.log(chunks.value)
+ * // [{ text: 'Hello ', match: false }, { text: 'World', match: true }]
+ * ```
*/
// Utilities
@@ -15,13 +27,49 @@ import { computed, toValue } from 'vue'
// Types
import type { ComputedRef, 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 = [number, number]
+/**
+ * A contiguous chunk of source text, flagged as matched or unmatched.
+ *
+ * @example
+ * ```ts
+ * import type { HighlightChunk } from '@vuetify/v0'
+ *
+ * const chunk: HighlightChunk = { text: 'Hello', match: true }
+ * ```
+ */
export interface HighlightChunk {
text: string
match: boolean
}
+/**
+ * Options accepted by {@link toHighlight}.
+ *
+ * @example
+ * ```ts
+ * import { toHighlight } from '@vuetify/v0'
+ *
+ * const chunks = toHighlight({
+ * text: () => props.text,
+ * query: () => props.query,
+ * ignoreCase: false,
+ * matchAll: false,
+ * })
+ * ```
+ */
export interface ToHighlightOptions {
/** The source string to split into chunks. */
text: MaybeRefOrGetter
@@ -30,6 +78,8 @@ export interface ToHighlightOptions {
/**
* Pre-computed `[start, end]` index pairs.
* When non-empty, takes priority over `query`. `matchAll` is ignored.
+ * Caller-supplied ranges are sorted and merged before chunking, so
+ * unsorted or overlapping input is handled gracefully.
*/
matches?: MaybeRefOrGetter
/**
@@ -41,6 +91,21 @@ export interface ToHighlightOptions {
ignoreCase?: MaybeRefOrGetter
}
+function mergeRanges (ranges: MatchRange[]): MatchRange[] {
+ const sorted = ranges
+ .filter(span => span[0] < span[1])
+ .toSorted((a, b) => a[0] - b[0])
+ const merged: MatchRange[] = []
+
+ for (const span of sorted) {
+ const last = merged.at(-1)
+ if (last && span[0] <= last[1]) last[1] = Math.max(last[1], span[1])
+ else merged.push([span[0], span[1]])
+ }
+
+ return merged
+}
+
function chunkText (text: string, ranges: MatchRange[]): HighlightChunk[] {
const chunks: HighlightChunk[] = []
let cursor = 0
@@ -63,32 +128,46 @@ function findRanges (text: string, query: string | string[], matchAll: boolean,
for (const term of terms) {
const needle = ignoreCase ? term.toLocaleLowerCase() : term
- let i = haystack.indexOf(needle)
+ let index = haystack.indexOf(needle)
- if (i !== -1) {
- spans.push([i, i + term.length])
+ if (index !== -1) {
+ spans.push([index, index + term.length])
if (matchAll) {
- i = haystack.indexOf(needle, i + term.length)
- while (i !== -1) {
- spans.push([i, i + term.length])
- i = haystack.indexOf(needle, i + term.length)
+ index = haystack.indexOf(needle, index + term.length)
+ while (index !== -1) {
+ spans.push([index, index + term.length])
+ index = haystack.indexOf(needle, index + term.length)
}
}
}
}
- spans.sort((a, b) => a[0] - b[0])
-
- const merged: MatchRange[] = []
- for (const span of spans) {
- const last = merged.at(-1)
- if (last && span[0] <= last[1]) last[1] = Math.max(last[1], span[1])
- else merged.push([...span])
- }
-
- return merged
+ return mergeRanges(spans)
}
+/**
+ * Splits text into matched and unmatched chunks.
+ *
+ * Priority: `matches` (when non-empty) → `query` → no-match fallback.
+ *
+ * @param options Source text plus optional `query`, `matches`, `matchAll`, `ignoreCase`.
+ * @returns A `ComputedRef` that recomputes when any reactive input changes.
+ *
+ * @see https://0.vuetifyjs.com/composables/transformers/to-highlight
+ *
+ * @example
+ * ```ts
+ * import { shallowRef } from 'vue'
+ * import { toHighlight } from '@vuetify/v0'
+ *
+ * const query = shallowRef('world')
+ * const chunks = toHighlight({ text: 'Hello World', query })
+ *
+ * console.log(chunks.value)
+ * // [{ text: 'Hello ', match: false }, { text: 'World', match: true }]
+ * ```
+ */
+/* #__NO_SIDE_EFFECTS__ */
export function toHighlight (options: ToHighlightOptions): ComputedRef {
return computed(() => {
const text = toValue(options.text)
@@ -97,7 +176,7 @@ export function toHighlight (options: ToHighlightOptions): ComputedRef
Date: Wed, 29 Apr 2026 14:12:45 -0500
Subject: [PATCH 3/9] docs(toHighlight): use shallowRef for primitive state in
examples
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ref('foo') for a string primitive violates PHILOSOPHY §4.1 — shallowRef
is the right primitive for booleans, numbers, strings. Examples are
the de facto template for downstream consumers, so the rule has to hold
there too. Codifies the convention in .claude/rules/docs.md so future
example authors see it without needing to read PHILOSOPHY first.
---
.claude/rules/docs.md | 1 +
apps/docs/src/examples/composables/to-highlight/basic.vue | 4 ++--
.../examples/composables/to-highlight/multiple-queries.vue | 4 ++--
3 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/.claude/rules/docs.md b/.claude/rules/docs.md
index af4f770e3..a52c16b12 100644
--- a/.claude/rules/docs.md
+++ b/.claude/rules/docs.md
@@ -193,6 +193,7 @@ The same depth rule does **not** apply to `## Usage` or `## Recipes` blocks —
- No `index.vue` pattern. [intent:308]
- Never `v-bind="attrs"` on children of non-renderless components — causes double-fire. Only use slot `attrs` in `renderless` mode where there is no wrapper element. [intent:206, intent:207]
- Prefer `v-slot="{ attrs }"` shorthand over ``. [intent:272, intent:273]
+- Reactivity primitive: `shallowRef` for primitive state (booleans, numbers, strings), `ref` only for objects/arrays. Same rule as source code — see PHILOSOPHY §4.1. Examples are read by every consumer and become the de facto template; if they reach for `ref('foo')`, downstream apps will too.
### Vue code fences
diff --git a/apps/docs/src/examples/composables/to-highlight/basic.vue b/apps/docs/src/examples/composables/to-highlight/basic.vue
index af92e4402..8d1fe17b5 100644
--- a/apps/docs/src/examples/composables/to-highlight/basic.vue
+++ b/apps/docs/src/examples/composables/to-highlight/basic.vue
@@ -1,8 +1,8 @@
diff --git a/apps/docs/src/examples/composables/to-highlight/match-ranges.vue b/apps/docs/src/examples/composables/to-highlight/match-ranges.vue
index 166ec2246..58b4446ac 100644
--- a/apps/docs/src/examples/composables/to-highlight/match-ranges.vue
+++ b/apps/docs/src/examples/composables/to-highlight/match-ranges.vue
@@ -17,7 +17,7 @@
[63, 68], // dozen
]
- const chunks = toHighlight({ text, matches })
+ const chunks = toHighlight(text, undefined, { matches })
diff --git a/apps/docs/src/examples/composables/to-highlight/multiple-queries.vue b/apps/docs/src/examples/composables/to-highlight/multiple-queries.vue
index a2882bdaa..5b1c0a05b 100644
--- a/apps/docs/src/examples/composables/to-highlight/multiple-queries.vue
+++ b/apps/docs/src/examples/composables/to-highlight/multiple-queries.vue
@@ -9,7 +9,7 @@
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 = toHighlight({ text, query })
+ const chunks = computed(() => toHighlight(text, query))
diff --git a/apps/docs/src/pages/composables/transformers/to-highlight.md b/apps/docs/src/pages/composables/transformers/to-highlight.md
index 00318e176..f948b14c3 100644
--- a/apps/docs/src/pages/composables/transformers/to-highlight.md
+++ b/apps/docs/src/pages/composables/transformers/to-highlight.md
@@ -2,7 +2,7 @@
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 — just a ComputedRef.
+ 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:
@@ -18,20 +18,19 @@ related:
# toHighlight
-Pure transformer that splits text into matched and unmatched chunks. Returns a `ComputedRef` — render it however you like.
+Pure transformer that splits text into matched and unmatched chunks. Returns a plain `HighlightChunk[]` — wrap the call in `computed()` for reactive recomputation.
## Usage
```ts collapse
+import { computed } from 'vue'
import { toHighlight } from '@vuetify/v0'
-const chunks = toHighlight({
- text: () => props.text,
- query: () => props.query,
- ignoreCase: true,
-})
+const chunks = computed(() =>
+ toHighlight(() => props.text, () => props.query, { ignoreCase: true })
+)
// chunks.value → [{ text: 'Hello ', match: false }, { text: 'World', match: true }]
```
@@ -56,20 +55,21 @@ flowchart LR
## Reactivity
-`toHighlight` returns a **`ComputedRef`**. The chunks array recomputes
-whenever any of its `MaybeRefOrGetter` inputs change — `text`, `query`, `matches`, `matchAll`,
-`ignoreCase` are all read through `toValue`, so refs and getters track automatically.
+`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 |
| - | :-: | - |
-| Reading `chunks.value` | | Standard `ComputedRef` access |
-| Mutating `text`, `query`, `matches` | | Refs and getters tracked through `toValue` |
-| Toggling `ignoreCase` or `matchAll` | | Same `MaybeRefOrGetter` contract |
+| Calling `toHighlight(text, query)` | | One-shot snapshot at call time |
+| Wrapping in `computed(() => toHighlight(...))` | | Re-runs when tracked refs change |
+| Passing refs or getters as arguments | | `toValue` unwraps them on every call |
| Mutating returned chunks | | Treat the array as derived; do not mutate |
> [!TIP] Reach for plain values, refs, or getters
-> Every option accepts `MaybeRefOrGetter`. Pass a literal for static input, a `Ref` for
-> v-model integration, or a getter (`() => props.text`) for prop-driven reactivity.
+> Every input accepts `MaybeRefOrGetter`. 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
@@ -78,11 +78,12 @@ whenever any of its `MaybeRefOrGetter` inputs change — `text`, `query`, `match
### Search input
-Live query against a paragraph. `toHighlight` returns a `ComputedRef` that recomputes
-whenever any reactive input changes — swap the `query` ref and the chunks update instantly
-without any manual wiring. Each `HighlightChunk` carries `{ text, match }`, so you control
-the full rendering: use a native `` for semantics and screen-reader compatibility,
-a `` for bold-only, or whatever your design calls for.
+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 `` for semantics
+and screen-reader compatibility, a `` for bold-only, or whatever your design
+calls for.
Matching is case-insensitive by default (`ignoreCase: true`). Set `ignoreCase: false` to
respect the exact casing in the source text.
@@ -175,13 +176,13 @@ the output is the same as if you had supplied the canonical sorted, non-overlapp
??? What happens when neither query nor matches is provided?
-The returned `ComputedRef` resolves to a single `[{ text: sourceText, match: false }]`
-chunk — the full string with no highlights. Safe to iterate without any guard.
+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 computed value with no DOM access. It is safe to call
-during SSR.
+Yes. `toHighlight` is a pure function with no DOM access and no reactive state. It is
+safe to call during SSR.
:::
diff --git a/packages/0/src/composables/toHighlight/index.test.ts b/packages/0/src/composables/toHighlight/index.test.ts
index 0bd0f3402..46b2ecee2 100644
--- a/packages/0/src/composables/toHighlight/index.test.ts
+++ b/packages/0/src/composables/toHighlight/index.test.ts
@@ -1,32 +1,28 @@
import { describe, expect, it } from 'vitest'
// Utilities
-import { shallowRef } from 'vue'
+import { computed, shallowRef } from 'vue'
import { toHighlight } from './index'
-function run (options: Parameters[0]) {
- return toHighlight(options).value
-}
-
describe('toHighlight', () => {
describe('pre-computed matches', () => {
it('should not emit a leading empty span when match starts at position 0', () => {
- expect(run({ text: 'foobar', matches: [[0, 3]] })).toStrictEqual([
+ expect(toHighlight('foobar', undefined, { matches: [[0, 3]] })).toStrictEqual([
{ text: 'foo', match: true },
{ text: 'bar', match: false },
])
})
it('should not emit a trailing empty span when match ends at text length', () => {
- expect(run({ text: 'foobar', matches: [[3, 6]] })).toStrictEqual([
+ expect(toHighlight('foobar', undefined, { matches: [[3, 6]] })).toStrictEqual([
{ text: 'foo', match: false },
{ text: 'bar', match: true },
])
})
it('should handle a mid-text match with surrounding non-match chunks', () => {
- expect(run({ text: 'foobar', matches: [[2, 4]] })).toStrictEqual([
+ expect(toHighlight('foobar', undefined, { matches: [[2, 4]] })).toStrictEqual([
{ text: 'fo', match: false },
{ text: 'ob', match: true },
{ text: 'ar', match: false },
@@ -34,7 +30,7 @@ describe('toHighlight', () => {
})
it('should handle multiple non-overlapping matches', () => {
- expect(run({ text: 'foobar', matches: [[0, 2], [4, 6]] })).toStrictEqual([
+ expect(toHighlight('foobar', undefined, { matches: [[0, 2], [4, 6]] })).toStrictEqual([
{ text: 'fo', match: true },
{ text: 'ob', match: false },
{ text: 'ar', match: true },
@@ -42,7 +38,7 @@ describe('toHighlight', () => {
})
it('should sort caller-supplied matches that arrive out of order', () => {
- expect(run({ text: 'foobar', matches: [[4, 6], [0, 2]] })).toStrictEqual([
+ expect(toHighlight('foobar', undefined, { matches: [[4, 6], [0, 2]] })).toStrictEqual([
{ text: 'fo', match: true },
{ text: 'ob', match: false },
{ text: 'ar', match: true },
@@ -50,13 +46,13 @@ describe('toHighlight', () => {
})
it('should merge caller-supplied matches that overlap', () => {
- expect(run({ text: 'foobar', matches: [[0, 4], [2, 6]] })).toStrictEqual([
+ expect(toHighlight('foobar', undefined, { matches: [[0, 4], [2, 6]] })).toStrictEqual([
{ text: 'foobar', match: true },
])
})
it('should merge caller-supplied matches that are adjacent', () => {
- expect(run({ text: 'foobar', matches: [[0, 3], [3, 6]] })).toStrictEqual([
+ expect(toHighlight('foobar', undefined, { matches: [[0, 3], [3, 6]] })).toStrictEqual([
{ text: 'foobar', match: true },
])
})
@@ -64,25 +60,25 @@ describe('toHighlight', () => {
it('should not mutate the caller-supplied matches array or its tuples', () => {
const ranges: [number, number][] = [[0, 4], [2, 6]]
const snapshot = ranges.map(r => [...r])
- run({ text: 'foobar', matches: ranges })
+ toHighlight('foobar', undefined, { matches: ranges })
expect(ranges.map(r => [...r])).toStrictEqual(snapshot)
})
it('should drop inverted ranges where start >= end', () => {
- expect(run({ text: 'foobar', matches: [[5, 3]] })).toStrictEqual([
+ expect(toHighlight('foobar', undefined, { matches: [[5, 3]] })).toStrictEqual([
{ text: 'foobar', match: false },
])
})
it('should drop zero-width ranges', () => {
- expect(run({ text: 'foobar', matches: [[3, 3], [0, 2]] })).toStrictEqual([
+ expect(toHighlight('foobar', undefined, { matches: [[3, 3], [0, 2]] })).toStrictEqual([
{ text: 'fo', match: true },
{ text: 'obar', match: false },
])
})
it('should clamp out-of-bounds end indices via String.slice semantics', () => {
- expect(run({ text: 'hi', matches: [[0, 999]] })).toStrictEqual([
+ expect(toHighlight('hi', undefined, { matches: [[0, 999]] })).toStrictEqual([
{ text: 'hi', match: true },
])
})
@@ -90,44 +86,44 @@ describe('toHighlight', () => {
describe('query string', () => {
it('should match case-insensitively by default', () => {
- expect(run({ text: 'Hello World', query: 'HELLO' })[0]).toStrictEqual({ text: 'Hello', match: true })
+ expect(toHighlight('Hello World', 'HELLO')[0]).toStrictEqual({ text: 'Hello', match: true })
})
it('should match case-sensitively when ignoreCase is false', () => {
- expect(run({ text: 'Hello World', query: 'HELLO', ignoreCase: false })).toStrictEqual([
+ expect(toHighlight('Hello World', 'HELLO', { ignoreCase: false })).toStrictEqual([
{ text: 'Hello World', match: false },
])
})
it('should find every occurrence by default (matchAll: true)', () => {
- expect(run({ text: 'aa bb aa', query: 'aa' }).filter(c => c.match).map(c => c.text))
+ expect(toHighlight('aa bb aa', 'aa').filter(c => c.match).map(c => c.text))
.toStrictEqual(['aa', 'aa'])
})
it('should find only the first occurrence when matchAll is false', () => {
- expect(run({ text: 'aa bb aa', query: 'aa', matchAll: false }).filter(c => c.match).map(c => c.text))
+ expect(toHighlight('aa bb aa', 'aa', { matchAll: false }).filter(c => c.match).map(c => c.text))
.toStrictEqual(['aa'])
})
it('should return a single no-match chunk when query has no match', () => {
- expect(run({ text: 'hello', query: 'xyz' })).toStrictEqual([{ text: 'hello', match: false }])
+ expect(toHighlight('hello', 'xyz')).toStrictEqual([{ text: 'hello', match: false }])
})
it('should merge overlapping spans from multiple queries', () => {
- expect(run({ text: 'foobar', query: ['foo', 'oba'] })).toStrictEqual([
+ expect(toHighlight('foobar', ['foo', 'oba'])).toStrictEqual([
{ text: 'fooba', match: true },
{ text: 'r', match: false },
])
})
it('should merge adjacent spans', () => {
- expect(run({ text: 'foobar', query: ['foo', 'bar'] })).toStrictEqual([
+ expect(toHighlight('foobar', ['foo', 'bar'])).toStrictEqual([
{ text: 'foobar', match: true },
])
})
it('should ignore empty strings in a query array', () => {
- expect(run({ text: 'hello', query: ['', 'ell'] })).toStrictEqual([
+ expect(toHighlight('hello', ['', 'ell'])).toStrictEqual([
{ text: 'h', match: false },
{ text: 'ell', match: true },
{ text: 'o', match: false },
@@ -137,7 +133,7 @@ describe('toHighlight', () => {
describe('priority and fallthrough', () => {
it('should use pre-computed matches over query when both are provided', () => {
- expect(run({ text: 'hello', matches: [[1, 3]], query: 'hello' })).toStrictEqual([
+ expect(toHighlight('hello', 'hello', { matches: [[1, 3]] })).toStrictEqual([
{ text: 'h', match: false },
{ text: 'el', match: true },
{ text: 'lo', match: false },
@@ -145,7 +141,7 @@ describe('toHighlight', () => {
})
it('should fall through to query when matches is an empty array', () => {
- expect(run({ text: 'hello', matches: [], query: 'ell' })).toStrictEqual([
+ expect(toHighlight('hello', 'ell', { matches: [] })).toStrictEqual([
{ text: 'h', match: false },
{ text: 'ell', match: true },
{ text: 'o', match: false },
@@ -153,11 +149,11 @@ describe('toHighlight', () => {
})
it('should return a single no-match chunk when neither query nor matches', () => {
- expect(run({ text: 'hello' })).toStrictEqual([{ text: 'hello', match: false }])
+ expect(toHighlight('hello')).toStrictEqual([{ text: 'hello', match: false }])
})
it('should ignore matchAll when matches is provided', () => {
- expect(run({ text: 'aabbaa', matches: [[0, 2], [4, 6]], matchAll: false })).toStrictEqual([
+ expect(toHighlight('aabbaa', undefined, { matches: [[0, 2], [4, 6]], matchAll: false })).toStrictEqual([
{ text: 'aa', match: true },
{ text: 'bb', match: false },
{ text: 'aa', match: true },
@@ -166,51 +162,53 @@ describe('toHighlight', () => {
})
describe('reactive inputs', () => {
- it('should recompute when reactive text changes', () => {
+ it('should snapshot reactive text at call time', () => {
const text = shallowRef('hello world')
- const chunks = toHighlight({ text, query: 'world' })
-
- expect(chunks.value[1]).toStrictEqual({ text: 'world', match: true })
+ expect(toHighlight(text, 'world')[1]).toStrictEqual({ text: 'world', match: true })
text.value = 'goodbye world'
- expect(chunks.value[0]).toStrictEqual({ text: 'goodbye ', match: false })
+ expect(toHighlight(text, 'world')[1]).toStrictEqual({ text: 'world', match: true })
})
- it('should recompute when reactive query changes', () => {
+ it('should snapshot reactive query at call time', () => {
const query = shallowRef('hello')
- const chunks = toHighlight({ text: 'hello world', query })
-
- expect(chunks.value[0]).toStrictEqual({ text: 'hello', match: true })
+ expect(toHighlight('hello world', query)[0]).toStrictEqual({ text: 'hello', match: true })
query.value = 'world'
- expect(chunks.value[1]).toStrictEqual({ text: 'world', match: true })
+ expect(toHighlight('hello world', query)[1]).toStrictEqual({ text: 'world', match: true })
})
- it('should recompute when reactive matches change', () => {
+ it('should snapshot reactive matches at call time', () => {
const matches = shallowRef<[number, number][]>([[0, 5]])
- const chunks = toHighlight({ text: 'hello world', matches })
-
- expect(chunks.value[0]).toStrictEqual({ text: 'hello', match: true })
+ expect(toHighlight('hello world', undefined, { matches })[0]).toStrictEqual({ text: 'hello', match: true })
matches.value = [[6, 11]]
- expect(chunks.value[1]).toStrictEqual({ text: 'world', match: true })
+ expect(toHighlight('hello world', undefined, { matches })[1]).toStrictEqual({ text: 'world', match: true })
})
it('should accept getter functions for any input', () => {
const source = shallowRef('hello world')
const term = shallowRef('world')
- const chunks = toHighlight({
- text: () => source.value,
- query: () => term.value,
+ const result = toHighlight(() => source.value, () => term.value, {
ignoreCase: () => true,
matchAll: () => true,
})
-
- expect(chunks.value[1]).toStrictEqual({ text: 'world', match: true })
+ expect(result[1]).toStrictEqual({ text: 'world', match: true })
source.value = 'goodbye world'
term.value = 'goodbye'
- expect(chunks.value[0]).toStrictEqual({ text: 'goodbye', match: true })
+ expect(toHighlight(() => source.value, () => term.value)[0])
+ .toStrictEqual({ text: 'goodbye', match: true })
+ })
+
+ it('should recompute when wrapped in computed and a reactive input changes', () => {
+ const text = shallowRef('hello world')
+ const chunks = computed(() => toHighlight(text, 'world'))
+
+ expect(chunks.value[1]).toStrictEqual({ text: 'world', match: true })
+
+ text.value = 'goodbye world'
+ expect(chunks.value[0]).toStrictEqual({ text: 'goodbye ', match: false })
})
})
})
diff --git a/packages/0/src/composables/toHighlight/index.ts b/packages/0/src/composables/toHighlight/index.ts
index 50ae2695b..aa318f329 100644
--- a/packages/0/src/composables/toHighlight/index.ts
+++ b/packages/0/src/composables/toHighlight/index.ts
@@ -4,28 +4,28 @@
* @see https://0.vuetifyjs.com/composables/transformers/to-highlight
*
* @remarks
- * Pure transformer — no DOM, no state, no registry. Splits text into matched and
- * unmatched chunks given a query string, an array of query strings, or pre-computed
- * `[start, end]` match ranges (e.g., from createFilter).
+ * Pure transformer — no DOM, no state, no registry, no reactivity. Splits text
+ * into matched and unmatched chunks given a query string, an array of query
+ * strings, or pre-computed `[start, end]` match ranges (e.g., from createFilter).
+ * Returns a plain array; wrap the call in `computed()` for reactive recomputation.
*
* @example
* ```ts
* import { toHighlight } from '@vuetify/v0'
*
- * const chunks = toHighlight({
- * text: 'Hello World',
- * query: 'world',
- * })
- * console.log(chunks.value)
+ * const chunks = toHighlight('Hello World', 'world')
* // [{ text: 'Hello ', match: false }, { text: 'World', match: true }]
* ```
*/
// Utilities
-import { computed, toValue } from 'vue'
+import { toValue } from 'vue'
+
+// Transformers
+import { toArray } from '#v0/composables/toArray'
// Types
-import type { ComputedRef, MaybeRefOrGetter } from 'vue'
+import type { MaybeRefOrGetter } from 'vue'
/**
* A `[start, end]` index pair where `end` is exclusive (matches
@@ -56,25 +56,19 @@ export interface HighlightChunk {
}
/**
- * Options accepted by {@link toHighlight}.
+ * Optional configuration for {@link toHighlight}.
*
* @example
* ```ts
* import { toHighlight } from '@vuetify/v0'
*
- * const chunks = toHighlight({
- * text: () => props.text,
- * query: () => props.query,
+ * const chunks = toHighlight('Hello World', 'WORLD', {
* ignoreCase: false,
* matchAll: false,
* })
* ```
*/
export interface ToHighlightOptions {
- /** The source string to split into chunks. */
- text: MaybeRefOrGetter
- /** One or more search terms. Case sensitivity controlled by `ignoreCase`. */
- query?: MaybeRefOrGetter
/**
* Pre-computed `[start, end]` index pairs.
* When non-empty, takes priority over `query`. `matchAll` is ignored.
@@ -122,12 +116,12 @@ function chunkText (text: string, ranges: MatchRange[]): HighlightChunk[] {
}
function findRanges (text: string, query: string | string[], matchAll: boolean, ignoreCase: boolean): MatchRange[] {
- const terms = (Array.isArray(query) ? query : [query]).filter(Boolean)
- const haystack = ignoreCase ? text.toLocaleLowerCase() : text
+ const terms = toArray(query).filter(Boolean)
+ const haystack = ignoreCase ? text.toLowerCase() : text
const spans: MatchRange[] = []
for (const term of terms) {
- const needle = ignoreCase ? term.toLocaleLowerCase() : term
+ const needle = ignoreCase ? term.toLowerCase() : term
let index = haystack.indexOf(needle)
if (index !== -1) {
@@ -148,41 +142,48 @@ function findRanges (text: string, query: string | string[], matchAll: boolean,
/**
* Splits text into matched and unmatched chunks.
*
- * Priority: `matches` (when non-empty) → `query` → no-match fallback.
+ * Pure transformer — returns a plain array. Wrap the call in `computed()` for
+ * reactive recomputation.
*
- * @param options Source text plus optional `query`, `matches`, `matchAll`, `ignoreCase`.
- * @returns A `ComputedRef` that recomputes when any reactive input changes.
+ * Priority: `options.matches` (when non-empty) → `query` → no-match fallback.
+ *
+ * @param text The source string to split.
+ * @param query One or more search terms. Case sensitivity controlled by `options.ignoreCase`.
+ * @param options Optional `matches`, `matchAll`, `ignoreCase`.
+ * @returns A `HighlightChunk[]` array.
*
* @see https://0.vuetifyjs.com/composables/transformers/to-highlight
*
* @example
* ```ts
- * import { shallowRef } from 'vue'
+ * import { computed, shallowRef } from 'vue'
* import { toHighlight } from '@vuetify/v0'
*
* const query = shallowRef('world')
- * const chunks = toHighlight({ text: 'Hello World', query })
+ * const chunks = computed(() => toHighlight('Hello World', query))
*
* console.log(chunks.value)
* // [{ text: 'Hello ', match: false }, { text: 'World', match: true }]
* ```
*/
/* #__NO_SIDE_EFFECTS__ */
-export function toHighlight (options: ToHighlightOptions): ComputedRef {
- return computed(() => {
- const text = toValue(options.text)
- const matches = toValue(options.matches)
- const query = toValue(options.query)
- const matchAll = toValue(options.matchAll) ?? true
- const ignoreCase = toValue(options.ignoreCase) ?? true
-
- if (matches?.length) return chunkText(text, mergeRanges(matches))
-
- if (query) {
- const ranges = findRanges(text, query, matchAll, ignoreCase)
- return ranges.length > 0 ? chunkText(text, ranges) : [{ text, match: false }]
- }
+export function toHighlight (
+ text: MaybeRefOrGetter,
+ query?: MaybeRefOrGetter,
+ options: ToHighlightOptions = {},
+): HighlightChunk[] {
+ const _text = toValue(text)
+ const _query = toValue(query)
+ const _matches = toValue(options.matches)
+ const matchAll = toValue(options.matchAll) ?? true
+ const ignoreCase = toValue(options.ignoreCase) ?? true
+
+ if (_matches?.length) return chunkText(_text, mergeRanges(_matches))
+
+ if (_query) {
+ const ranges = findRanges(_text, _query, matchAll, ignoreCase)
+ return ranges.length > 0 ? chunkText(_text, ranges) : [{ text: _text, match: false }]
+ }
- return [{ text, match: false }]
- })
+ return [{ text: _text, match: false }]
}
From 24f0d6a21ef1d0bd77234907fd18ac92dd34cdfd Mon Sep 17 00:00:00 2001
From: J-Sek
Date: Thu, 14 May 2026 15:36:55 +0200
Subject: [PATCH 6/9] aligment for easier migration
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- TS aligmnent for VHighlight in Vuetify
- `toLowerCase` » `toLocaleLowerCase`
---
.../0/src/composables/toHighlight/index.ts | 20 +++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/packages/0/src/composables/toHighlight/index.ts b/packages/0/src/composables/toHighlight/index.ts
index aa318f329..290b06d9c 100644
--- a/packages/0/src/composables/toHighlight/index.ts
+++ b/packages/0/src/composables/toHighlight/index.ts
@@ -38,7 +38,7 @@ import type { MaybeRefOrGetter } from 'vue'
* const ranges: MatchRange[] = [[0, 5], [12, 17]]
* ```
*/
-export type MatchRange = [number, number]
+export type MatchRange = readonly [number, number]
/**
* A contiguous chunk of source text, flagged as matched or unmatched.
@@ -75,21 +75,21 @@ 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`, default) or only the first per term (`false`).
+ * Highlight every occurrence (`true`) or only the first per term (`false`, default).
* Ignored when `matches` is provided.
*/
matchAll?: MaybeRefOrGetter
- /** Case-insensitive matching. Default `true`. */
+ /** Case-insensitive matching. Default `false`. */
ignoreCase?: MaybeRefOrGetter
}
-function mergeRanges (ranges: MatchRange[]): MatchRange[] {
+function mergeRanges (ranges: readonly MatchRange[]): MatchRange[] {
const sorted = ranges
.filter(span => span[0] < span[1])
.toSorted((a, b) => a[0] - b[0])
- const merged: MatchRange[] = []
+ const merged: [number, number][] = []
for (const span of sorted) {
const last = merged.at(-1)
@@ -100,7 +100,7 @@ function mergeRanges (ranges: MatchRange[]): MatchRange[] {
return merged
}
-function chunkText (text: string, ranges: MatchRange[]): HighlightChunk[] {
+function chunkText (text: string, ranges: readonly MatchRange[]): HighlightChunk[] {
const chunks: HighlightChunk[] = []
let cursor = 0
@@ -117,11 +117,11 @@ function chunkText (text: string, ranges: MatchRange[]): HighlightChunk[] {
function findRanges (text: string, query: string | string[], matchAll: boolean, ignoreCase: boolean): MatchRange[] {
const terms = toArray(query).filter(Boolean)
- const haystack = ignoreCase ? text.toLowerCase() : text
- const spans: MatchRange[] = []
+ const haystack = ignoreCase ? text.toLocaleLowerCase() : text
+ const spans: [number, number][] = []
for (const term of terms) {
- const needle = ignoreCase ? term.toLowerCase() : term
+ const needle = ignoreCase ? term.toLocaleLowerCase() : term
let index = haystack.indexOf(needle)
if (index !== -1) {
From bad41d474719bffaffc40ef0b7f746531bfb4b01 Mon Sep 17 00:00:00 2001
From: J-Sek
Date: Thu, 14 May 2026 15:37:53 +0200
Subject: [PATCH 7/9] flip `matchAll` and `ignoreCase` to false by default
---
.../composables/to-highlight/basic.vue | 2 +-
.../to-highlight/multiple-queries.vue | 2 +-
.../composables/transformers/to-highlight.md | 4 ++--
.../src/composables/toHighlight/index.test.ts | 23 ++++++++++---------
.../0/src/composables/toHighlight/index.ts | 12 +++++-----
5 files changed, 22 insertions(+), 21 deletions(-)
diff --git a/apps/docs/src/examples/composables/to-highlight/basic.vue b/apps/docs/src/examples/composables/to-highlight/basic.vue
index d8e5cae69..032e45216 100644
--- a/apps/docs/src/examples/composables/to-highlight/basic.vue
+++ b/apps/docs/src/examples/composables/to-highlight/basic.vue
@@ -5,7 +5,7 @@
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))
+ const chunks = computed(() => toHighlight(text, query, { ignoreCase: true, matchAll: true }))
diff --git a/apps/docs/src/examples/composables/to-highlight/multiple-queries.vue b/apps/docs/src/examples/composables/to-highlight/multiple-queries.vue
index 5b1c0a05b..b78238504 100644
--- a/apps/docs/src/examples/composables/to-highlight/multiple-queries.vue
+++ b/apps/docs/src/examples/composables/to-highlight/multiple-queries.vue
@@ -9,7 +9,7 @@
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))
+ const chunks = computed(() => toHighlight(text, query, { ignoreCase: true, matchAll: true }))
diff --git a/apps/docs/src/pages/composables/transformers/to-highlight.md b/apps/docs/src/pages/composables/transformers/to-highlight.md
index f948b14c3..a046a3741 100644
--- a/apps/docs/src/pages/composables/transformers/to-highlight.md
+++ b/apps/docs/src/pages/composables/transformers/to-highlight.md
@@ -85,8 +85,8 @@ markup re-renders without any manual wiring. Each `HighlightChunk` carries
and screen-reader compatibility, a `` for bold-only, or whatever your design
calls for.
-Matching is case-insensitive by default (`ignoreCase: true`). Set `ignoreCase: false` to
-respect the exact casing in the source text.
+Matching is case-sensitive by default. Set `ignoreCase: true` to
+match regardless of casing in the source text.
:::
diff --git a/packages/0/src/composables/toHighlight/index.test.ts b/packages/0/src/composables/toHighlight/index.test.ts
index 46b2ecee2..1e1cfe177 100644
--- a/packages/0/src/composables/toHighlight/index.test.ts
+++ b/packages/0/src/composables/toHighlight/index.test.ts
@@ -85,26 +85,27 @@ describe('toHighlight', () => {
})
describe('query string', () => {
- it('should match case-insensitively by default', () => {
- expect(toHighlight('Hello World', 'HELLO')[0]).toStrictEqual({ text: 'Hello', match: true })
- })
-
- it('should match case-sensitively when ignoreCase is false', () => {
- expect(toHighlight('Hello World', 'HELLO', { ignoreCase: false })).toStrictEqual([
+ it('should match case-sensitively by default', () => {
+ expect(toHighlight('Hello World', 'HELLO')).toStrictEqual([
{ text: 'Hello World', match: false },
])
})
- it('should find every occurrence by default (matchAll: true)', () => {
- expect(toHighlight('aa bb aa', 'aa').filter(c => c.match).map(c => c.text))
- .toStrictEqual(['aa', 'aa'])
+ it('should match case-insensitively when ignoreCase is true', () => {
+ expect(toHighlight('Hello World', 'HELLO', { ignoreCase: true })[0])
+ .toStrictEqual({ text: 'Hello', match: true })
})
- it('should find only the first occurrence when matchAll is false', () => {
- expect(toHighlight('aa bb aa', 'aa', { matchAll: false }).filter(c => c.match).map(c => c.text))
+ it('should find only the first occurrence by default (matchAll: false)', () => {
+ expect(toHighlight('aa bb aa', 'aa').filter(c => c.match).map(c => c.text))
.toStrictEqual(['aa'])
})
+ it('should find every occurrence when matchAll is true', () => {
+ expect(toHighlight('aa bb aa', 'aa', { matchAll: true }).filter(c => c.match).map(c => c.text))
+ .toStrictEqual(['aa', 'aa'])
+ })
+
it('should return a single no-match chunk when query has no match', () => {
expect(toHighlight('hello', 'xyz')).toStrictEqual([{ text: 'hello', match: false }])
})
diff --git a/packages/0/src/composables/toHighlight/index.ts b/packages/0/src/composables/toHighlight/index.ts
index 290b06d9c..5561b23db 100644
--- a/packages/0/src/composables/toHighlight/index.ts
+++ b/packages/0/src/composables/toHighlight/index.ts
@@ -13,7 +13,7 @@
* ```ts
* import { toHighlight } from '@vuetify/v0'
*
- * const chunks = toHighlight('Hello World', 'world')
+ * const chunks = toHighlight('Hello World', 'World')
* // [{ text: 'Hello ', match: false }, { text: 'World', match: true }]
* ```
*/
@@ -63,8 +63,8 @@ export interface HighlightChunk {
* import { toHighlight } from '@vuetify/v0'
*
* const chunks = toHighlight('Hello World', 'WORLD', {
- * ignoreCase: false,
- * matchAll: false,
+ * ignoreCase: true,
+ * matchAll: true,
* })
* ```
*/
@@ -159,7 +159,7 @@ function findRanges (text: string, query: string | string[], matchAll: boolean,
* import { computed, shallowRef } from 'vue'
* import { toHighlight } from '@vuetify/v0'
*
- * const query = shallowRef('world')
+ * const query = shallowRef('World')
* const chunks = computed(() => toHighlight('Hello World', query))
*
* console.log(chunks.value)
@@ -175,8 +175,8 @@ export function toHighlight (
const _text = toValue(text)
const _query = toValue(query)
const _matches = toValue(options.matches)
- const matchAll = toValue(options.matchAll) ?? true
- const ignoreCase = toValue(options.ignoreCase) ?? true
+ const matchAll = toValue(options.matchAll) ?? false
+ const ignoreCase = toValue(options.ignoreCase) ?? false
if (_matches?.length) return chunkText(_text, mergeRanges(_matches))
From b5e4ff9f6553d05e275a24090517a25c9cc0ab6c Mon Sep 17 00:00:00 2001
From: J-Sek
Date: Thu, 14 May 2026 17:34:38 +0200
Subject: [PATCH 8/9] docs: update example for `createFilter`
---
.../composables/create-filter/live-search.vue | 24 ++++++++++++-------
1 file changed, 16 insertions(+), 8 deletions(-)
diff --git a/apps/docs/src/examples/composables/create-filter/live-search.vue b/apps/docs/src/examples/composables/create-filter/live-search.vue
index 3dd2ca1ac..f96773975 100644
--- a/apps/docs/src/examples/composables/create-filter/live-search.vue
+++ b/apps/docs/src/examples/composables/create-filter/live-search.vue
@@ -1,5 +1,5 @@
@@ -62,9 +58,21 @@
class="px-4 py-3 flex items-center justify-between hover:bg-surface-tint transition-colors"
>
-
+
+
+ {{ chunk.text }}
+ {{ chunk.text }}
+
+
+
/
-
+
+
+
+ {{ chunk.text }}
+ {{ chunk.text }}
+
+
{{ city.population }}
From 95bdcb7d50290c74c2c8c521c0c5757743beff97 Mon Sep 17 00:00:00 2001
From: J-Sek
Date: Thu, 14 May 2026 17:41:37 +0200
Subject: [PATCH 9/9] lint fix
---
packages/0/src/composables/toHighlight/index.test.ts | 4 ++--
packages/0/src/composables/toHighlight/index.ts | 6 +++---
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/packages/0/src/composables/toHighlight/index.test.ts b/packages/0/src/composables/toHighlight/index.test.ts
index 1e1cfe177..011686d3c 100644
--- a/packages/0/src/composables/toHighlight/index.test.ts
+++ b/packages/0/src/composables/toHighlight/index.test.ts
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'
+import { toHighlight } from './index'
+
// Utilities
import { computed, shallowRef } from 'vue'
-import { toHighlight } from './index'
-
describe('toHighlight', () => {
describe('pre-computed matches', () => {
it('should not emit a leading empty span when match starts at position 0', () => {
diff --git a/packages/0/src/composables/toHighlight/index.ts b/packages/0/src/composables/toHighlight/index.ts
index 5561b23db..8484f941d 100644
--- a/packages/0/src/composables/toHighlight/index.ts
+++ b/packages/0/src/composables/toHighlight/index.ts
@@ -18,12 +18,12 @@
* ```
*/
-// Utilities
-import { toValue } from 'vue'
-
// Transformers
import { toArray } from '#v0/composables/toArray'
+// Utilities
+import { toValue } from 'vue'
+
// Types
import type { MaybeRefOrGetter } from 'vue'