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

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 }}
+

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 }} +

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 `