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" >
- + + + + / - + + + +
{{ city.population }} 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..032e45216 --- /dev/null +++ b/apps/docs/src/examples/composables/to-highlight/basic.vue @@ -0,0 +1,34 @@ + + + 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..58b4446ac --- /dev/null +++ b/apps/docs/src/examples/composables/to-highlight/match-ranges.vue @@ -0,0 +1,46 @@ + + + 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..b78238504 --- /dev/null +++ b/apps/docs/src/examples/composables/to-highlight/multiple-queries.vue @@ -0,0 +1,46 @@ + + + diff --git a/apps/docs/src/pages/composables/index.md b/apps/docs/src/pages/composables/index.md index 7820600f6..bdb8503df 100644 --- a/apps/docs/src/pages/composables/index.md +++ b/apps/docs/src/pages/composables/index.md @@ -334,5 +334,6 @@ Value transformation utilities. | - | - | | [toArray](/composables/transformers/to-array) | Convert any value to an array | | [toElement](/composables/transformers/to-element) | Resolve refs, getters, or component instances to a plain DOM element | +| [toHighlight](/composables/transformers/to-highlight) | Split text into matched and unmatched chunks for query highlighting | | [toReactive](/composables/transformers/to-reactive) | Convert MaybeRef objects to reactive proxies | 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..a046a3741 --- /dev/null +++ b/apps/docs/src/pages/composables/transformers/to-highlight.md @@ -0,0 +1,188 @@ +--- +title: toHighlight - Text Search Highlighting for Vue 3 +meta: + - name: description + content: Pure Vue 3 transformer that splits text into matched and unmatched chunks given a query string or pre-computed match ranges. No DOM, no state, no reactivity — just a HighlightChunk array. Wrap in computed() for reactive recomputation. + - name: keywords + content: highlight, text search, mark, query, search terms, Vue 3, headless, transformer, filter, autocomplete, MatchRange +features: + category: Composable + label: 'E: toHighlight' + github: /composables/toHighlight/ + level: 1 +related: + - /composables/data/create-filter + - /components/forms/combobox + - /composables/transformers/to-array +--- + +# toHighlight + +Pure transformer that splits text into matched and unmatched chunks. Returns a plain `HighlightChunk[]` — wrap the call in `computed()` for reactive recomputation. + + + +## Usage + +```ts collapse +import { computed } from 'vue' +import { toHighlight } from '@vuetify/v0' + +const chunks = computed(() => + toHighlight(() => props.text, () => props.query, { ignoreCase: true }) +) +// chunks.value → [{ text: 'Hello ', match: false }, { text: 'World', match: true }] +``` + +## Architecture + +`toHighlight` resolves its input through a fixed priority order: + +```mermaid "Highlight Resolution" +flowchart LR + options[Options] --> matches{matches?} + matches -- non-empty --> normalize[sort + merge ranges] + matches -- empty / none --> query{query?} + query -- truthy --> find[findRanges] + query -- empty / none --> noop[full text, match: false] + find --> ranges{matches found?} + ranges -- yes --> chunk[chunkText] + ranges -- no --> noop + normalize --> chunk + chunk --> chunks[HighlightChunk array] + noop --> chunks +``` + +## Reactivity + +`toHighlight` is a pure transformer — it reads each input through `toValue` once and +returns a plain `HighlightChunk[]`. To make the result track upstream changes, wrap the +call in `computed()` (or any reactive scope). The function itself creates no reactivity. + +| Behavior | Reactive | Notes | +| - | :-: | - | +| Calling `toHighlight(text, query)` | | 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 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 + +::: example +/composables/to-highlight/basic + +### Search input + +Live query against a paragraph. The example wraps `toHighlight` in `computed()` so the +chunks update instantly when the `query` ref changes — swap the search term and the +markup re-renders without any manual wiring. Each `HighlightChunk` carries +`{ text, match }`, so you control the full rendering: use a native `` for semantics +and screen-reader compatibility, a `` for bold-only, or whatever your design +calls for. + +Matching is case-sensitive by default. Set `ignoreCase: true` to +match regardless of casing in the source text. + +::: + +::: example +/composables/to-highlight/multiple-queries + +### Multiple queries + +Pass an array to `query` to highlight several terms at once. Overlapping or adjacent +spans are merged into a single highlight — `['foo', 'oba']` against `'foobar'` produces +one chunk `{ text: 'fooba', match: true }` rather than two. This matches how most +search engines report matches and avoids nested or duplicated highlights. + +The example splits a comma-separated input into an array via `computed`. You can also +derive the array from a tag list, token stream, or search-engine suggestion list — anything +that produces `string[]`. + +When `matchAll` is `false`, only the first occurrence of each term is highlighted. Useful +for "jump to first match" UI patterns where highlighting every hit would be distracting. + +::: + +::: example +/composables/to-highlight/match-ranges + +### Pre-computed ranges + +Skip the query entirely and supply exact `[start, end]` index pairs via `matches`. +When `matches` is a non-empty array it takes priority over `query`, and `matchAll` +is ignored — the caller is asserting full control over which spans to highlight. + +Pre-computed ranges are useful when: + +- A full-text search engine returns character offsets directly alongside results. +- You're combining `createFilter` from `@vuetify/v0` with its forthcoming `matches` output + — a single filter pass yields both the filtered items *and* their highlight spans, so you + don't run the query algorithm twice. +- You need to highlight structurally identified tokens (syntax spans, named entities, + diff hunks) rather than substring matches. + +The `MatchRange` type is `[number, number]` — a `[start, end]` pair where `end` is +exclusive (matching JavaScript's `String.prototype.slice` convention). + +| Priority | Source | Condition | +|----------|--------|-----------| +| 1 | `matches` | non-empty array | +| 2 | `query` | string or string[] | +| 3 | No-match fallback | neither provided | + +::: + +## Accessibility + +Wrap matched chunks in the native `` element. It carries the implicit ARIA role +`mark` and is announced by screen readers as highlighted or marked text. No additional +ARIA attributes are needed on the wrapper element. + +> [!TIP] +> WCAG 1.4.3 (Contrast — Minimum) applies to highlighted text. Ensure sufficient contrast +> between the mark background color and the surrounding text. + +## Questions + +::: faq +??? Does toHighlight preserve the original casing? + +Yes. The source `text` string is sliced at match boundaries, so the original characters +(including casing, punctuation, and whitespace) are always preserved in the output chunks. +`ignoreCase` affects only the matching logic, not the returned text. + +??? Can I use it with createFilter results? + +Yes. The `matches` option accepts `MatchRange[]` — `[start, end]` pairs. Once +`createFilter` exposes positional data, pass the result directly and skip the query path. + +??? How does it handle overlapping multi-term matches? + +Overlapping or adjacent spans are merged before the chunks array is produced. +`['foo', 'oba']` against `'foobar'` yields `[{ text: 'fooba', match: true }, { text: 'r', match: false }]` +rather than two separate matches. + +??? Are caller-supplied match ranges normalized? + +Yes. Ranges passed via the `matches` option are sorted by start index and merged on +overlap or adjacency before chunking. Pass `[[4, 6], [0, 2]]` or `[[0, 4], [2, 6]]` and +the output is the same as if you had supplied the canonical sorted, non-overlapping form. + +??? What happens when neither query nor matches is provided? + +The function returns a single `[{ text: sourceText, match: false }]` chunk — the full +string with no highlights. Safe to iterate without any guard. + +??? Is it SSR-safe? + +Yes. `toHighlight` is a pure function with no DOM access and no reactive state. It is +safe to call during SSR. +::: + + diff --git a/apps/docs/src/typed-router.d.ts b/apps/docs/src/typed-router.d.ts index f97331bda..8cb843708 100644 --- a/apps/docs/src/typed-router.d.ts +++ b/apps/docs/src/typed-router.d.ts @@ -800,6 +800,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', @@ -1739,6 +1746,12 @@ declare module 'vue-router/auto-routes' { views: | never } + 'src/pages/composables/transformers/to-highlight.md': { + routes: + | '/composables/transformers/to-highlight' + views: + | never + } 'src/pages/composables/transformers/to-reactive.md': { routes: | '/composables/transformers/to-reactive' 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/README.md b/packages/0/README.md index 7269e6e4b..4a69665ba 100644 --- a/packages/0/README.md +++ b/packages/0/README.md @@ -208,6 +208,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/index.ts b/packages/0/src/composables/index.ts index 0f1363371..4710c36ba 100644 --- a/packages/0/src/composables/index.ts +++ b/packages/0/src/composables/index.ts @@ -31,6 +31,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..011686d3c --- /dev/null +++ b/packages/0/src/composables/toHighlight/index.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest' + +import { toHighlight } from './index' + +// Utilities +import { computed, shallowRef } from 'vue' + +describe('toHighlight', () => { + describe('pre-computed matches', () => { + it('should not emit a leading empty span when match starts at position 0', () => { + 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(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(toHighlight('foobar', undefined, { matches: [[2, 4]] })).toStrictEqual([ + { text: 'fo', match: false }, + { text: 'ob', match: true }, + { text: 'ar', match: false }, + ]) + }) + + it('should handle multiple non-overlapping matches', () => { + expect(toHighlight('foobar', undefined, { matches: [[0, 2], [4, 6]] })).toStrictEqual([ + { text: 'fo', match: true }, + { text: 'ob', match: false }, + { text: 'ar', match: true }, + ]) + }) + + it('should sort caller-supplied matches that arrive out of order', () => { + expect(toHighlight('foobar', undefined, { 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(toHighlight('foobar', undefined, { matches: [[0, 4], [2, 6]] })).toStrictEqual([ + { text: 'foobar', match: true }, + ]) + }) + + it('should merge caller-supplied matches that are adjacent', () => { + expect(toHighlight('foobar', undefined, { 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]) + toHighlight('foobar', undefined, { matches: ranges }) + expect(ranges.map(r => [...r])).toStrictEqual(snapshot) + }) + + it('should drop inverted ranges where start >= end', () => { + expect(toHighlight('foobar', undefined, { matches: [[5, 3]] })).toStrictEqual([ + { text: 'foobar', match: false }, + ]) + }) + + it('should drop zero-width ranges', () => { + 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(toHighlight('hi', undefined, { matches: [[0, 999]] })).toStrictEqual([ + { text: 'hi', match: true }, + ]) + }) + }) + + describe('query string', () => { + it('should match case-sensitively by default', () => { + expect(toHighlight('Hello World', 'HELLO')).toStrictEqual([ + { text: 'Hello World', match: false }, + ]) + }) + + 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 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 }]) + }) + + it('should merge overlapping spans from multiple queries', () => { + expect(toHighlight('foobar', ['foo', 'oba'])).toStrictEqual([ + { text: 'fooba', match: true }, + { text: 'r', match: false }, + ]) + }) + + it('should merge adjacent spans', () => { + expect(toHighlight('foobar', ['foo', 'bar'])).toStrictEqual([ + { text: 'foobar', match: true }, + ]) + }) + + it('should ignore empty strings in a query array', () => { + expect(toHighlight('hello', ['', '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(toHighlight('hello', 'hello', { matches: [[1, 3]] })).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(toHighlight('hello', 'ell', { matches: [] })).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(toHighlight('hello')).toStrictEqual([{ text: 'hello', match: false }]) + }) + + it('should ignore matchAll when matches is provided', () => { + expect(toHighlight('aabbaa', undefined, { 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 snapshot reactive text at call time', () => { + const text = shallowRef('hello world') + expect(toHighlight(text, 'world')[1]).toStrictEqual({ text: 'world', match: true }) + + text.value = 'goodbye world' + expect(toHighlight(text, 'world')[1]).toStrictEqual({ text: 'world', match: true }) + }) + + it('should snapshot reactive query at call time', () => { + const query = shallowRef('hello') + expect(toHighlight('hello world', query)[0]).toStrictEqual({ text: 'hello', match: true }) + + query.value = 'world' + expect(toHighlight('hello world', query)[1]).toStrictEqual({ text: 'world', match: true }) + }) + + it('should snapshot reactive matches at call time', () => { + const matches = shallowRef<[number, number][]>([[0, 5]]) + expect(toHighlight('hello world', undefined, { matches })[0]).toStrictEqual({ text: 'hello', match: true }) + + matches.value = [[6, 11]] + 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 result = toHighlight(() => source.value, () => term.value, { + ignoreCase: () => true, + matchAll: () => true, + }) + expect(result[1]).toStrictEqual({ text: 'world', match: true }) + + source.value = 'goodbye world' + term.value = 'goodbye' + 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 new file mode 100644 index 000000000..8484f941d --- /dev/null +++ b/packages/0/src/composables/toHighlight/index.ts @@ -0,0 +1,189 @@ +/** + * @module toHighlight + * + * @see https://0.vuetifyjs.com/composables/transformers/to-highlight + * + * @remarks + * 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('Hello World', 'World') + * // [{ text: 'Hello ', match: false }, { text: 'World', match: true }] + * ``` + */ + +// Transformers +import { toArray } from '#v0/composables/toArray' + +// Utilities +import { toValue } from 'vue' + +// Types +import type { MaybeRefOrGetter } from 'vue' + +/** + * A `[start, end]` index pair where `end` is exclusive (matches + * `String.prototype.slice` convention). + * + * @example + * ```ts + * import type { MatchRange } from '@vuetify/v0' + * + * const ranges: MatchRange[] = [[0, 5], [12, 17]] + * ``` + */ +export type MatchRange = readonly [number, number] + +/** + * 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 +} + +/** + * Optional configuration for {@link toHighlight}. + * + * @example + * ```ts + * import { toHighlight } from '@vuetify/v0' + * + * const chunks = toHighlight('Hello World', 'WORLD', { + * ignoreCase: true, + * matchAll: true, + * }) + * ``` + */ +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 + /** + * Highlight every occurrence (`true`) or only the first per term (`false`, default). + * Ignored when `matches` is provided. + */ + matchAll?: MaybeRefOrGetter + /** Case-insensitive matching. Default `false`. */ + ignoreCase?: MaybeRefOrGetter +} + +function mergeRanges (ranges: readonly MatchRange[]): MatchRange[] { + const sorted = ranges + .filter(span => span[0] < span[1]) + .toSorted((a, b) => a[0] - b[0]) + const merged: [number, number][] = [] + + 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: readonly 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 = toArray(query).filter(Boolean) + const haystack = ignoreCase ? text.toLocaleLowerCase() : text + const spans: [number, number][] = [] + + for (const term of terms) { + const needle = ignoreCase ? term.toLocaleLowerCase() : term + let index = haystack.indexOf(needle) + + if (index !== -1) { + spans.push([index, index + term.length]) + if (matchAll) { + index = haystack.indexOf(needle, index + term.length) + while (index !== -1) { + spans.push([index, index + term.length]) + index = haystack.indexOf(needle, index + term.length) + } + } + } + } + + return mergeRanges(spans) +} + +/** + * Splits text into matched and unmatched chunks. + * + * Pure transformer — returns a plain array. Wrap the call in `computed()` for + * reactive recomputation. + * + * 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 { computed, shallowRef } from 'vue' + * import { toHighlight } from '@vuetify/v0' + * + * const query = shallowRef('World') + * 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 ( + 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) ?? false + const ignoreCase = toValue(options.ignoreCase) ?? false + + 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: _text, match: false }] +} diff --git a/packages/0/src/maturity.json b/packages/0/src/maturity.json index d5cf9b424..bb55a8c07 100644 --- a/packages/0/src/maturity.json +++ b/packages/0/src/maturity.json @@ -276,6 +276,11 @@ "since": "0.1.0", "category": "transformers" }, + "toHighlight": { + "level": "preview", + "since": null, + "category": "transformers" + }, "toReactive": { "level": "preview", "since": "0.1.0",