From fc07da837d94284bf871b70ebdea6341d789fdb2 Mon Sep 17 00:00:00 2001 From: J-Sek Date: Fri, 24 Apr 2026 18:40:32 +0200 Subject: [PATCH 1/8] feat(VHighlight): add new component --- packages/docs/src/data/nav.json | 4 + packages/docs/src/data/page-to-api.json | 1 + .../v-highlight/misc-selection-match.vue | 61 ++++++++++++++ .../src/examples/v-highlight/prop-matches.vue | 58 ++++++++++++++ .../src/examples/v-highlight/prop-query.vue | 22 +++++ .../examples/v-highlight/props-mark-class.vue | 33 ++++++++ .../docs/src/examples/v-highlight/usage.vue | 42 ++++++++++ .../src/pages/en/components/highlights.md | 80 +++++++++++++++++++ .../docs/src/pages/en/labs/introduction.md | 1 + .../VAutocomplete/VAutocomplete.tsx | 5 +- .../src/components/VCombobox/VCombobox.tsx | 5 +- .../src/components/VSelect/VSelect.tsx | 5 +- .../src/labs/VHighlight/VHighlight.sass | 10 +++ .../src/labs/VHighlight/VHighlight.tsx | 51 ++++++++++++ .../VHighlight/__tests__/highlight.spec.ts | 75 +++++++++++++++++ .../src/labs/VHighlight/_variables.scss | 4 + .../vuetify/src/labs/VHighlight/highlight.ts | 74 +++++++++++++++++ packages/vuetify/src/labs/VHighlight/index.ts | 1 + packages/vuetify/src/labs/components.ts | 1 + 19 files changed, 527 insertions(+), 6 deletions(-) create mode 100644 packages/docs/src/examples/v-highlight/misc-selection-match.vue create mode 100644 packages/docs/src/examples/v-highlight/prop-matches.vue create mode 100644 packages/docs/src/examples/v-highlight/prop-query.vue create mode 100644 packages/docs/src/examples/v-highlight/props-mark-class.vue create mode 100644 packages/docs/src/examples/v-highlight/usage.vue create mode 100644 packages/docs/src/pages/en/components/highlights.md create mode 100644 packages/vuetify/src/labs/VHighlight/VHighlight.sass create mode 100644 packages/vuetify/src/labs/VHighlight/VHighlight.tsx create mode 100644 packages/vuetify/src/labs/VHighlight/__tests__/highlight.spec.ts create mode 100644 packages/vuetify/src/labs/VHighlight/_variables.scss create mode 100644 packages/vuetify/src/labs/VHighlight/highlight.ts create mode 100644 packages/vuetify/src/labs/VHighlight/index.ts diff --git a/packages/docs/src/data/nav.json b/packages/docs/src/data/nav.json index 2f1b8979543..80209ffcd71 100644 --- a/packages/docs/src/data/nav.json +++ b/packages/docs/src/data/nav.json @@ -284,6 +284,10 @@ "title": "file-upload", "subfolder": "components" }, + { + "title": "highlights", + "subfolder": "components" + }, { "title": "icon-buttons", "subfolder": "components" diff --git a/packages/docs/src/data/page-to-api.json b/packages/docs/src/data/page-to-api.json index 8893d5219b0..9ce1ead7a63 100644 --- a/packages/docs/src/data/page-to-api.json +++ b/packages/docs/src/data/page-to-api.json @@ -97,6 +97,7 @@ "components/footers": ["VFooter"], "components/forms": ["VForm"], "components/grids": ["VCol", "VContainer", "VRow", "VSpacer"], + "components/highlights": ["VHighlight"], "components/hotkeys": ["VHotkey"], "components/hover": ["VHover"], "components/icon-buttons": ["VIconBtn"], diff --git a/packages/docs/src/examples/v-highlight/misc-selection-match.vue b/packages/docs/src/examples/v-highlight/misc-selection-match.vue new file mode 100644 index 00000000000..72fa588acdf --- /dev/null +++ b/packages/docs/src/examples/v-highlight/misc-selection-match.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/packages/docs/src/examples/v-highlight/prop-matches.vue b/packages/docs/src/examples/v-highlight/prop-matches.vue new file mode 100644 index 00000000000..82be1c9106f --- /dev/null +++ b/packages/docs/src/examples/v-highlight/prop-matches.vue @@ -0,0 +1,58 @@ + + + diff --git a/packages/docs/src/examples/v-highlight/prop-query.vue b/packages/docs/src/examples/v-highlight/prop-query.vue new file mode 100644 index 00000000000..aa7a1ca6b15 --- /dev/null +++ b/packages/docs/src/examples/v-highlight/prop-query.vue @@ -0,0 +1,22 @@ + + + diff --git a/packages/docs/src/examples/v-highlight/props-mark-class.vue b/packages/docs/src/examples/v-highlight/props-mark-class.vue new file mode 100644 index 00000000000..69309e09ee1 --- /dev/null +++ b/packages/docs/src/examples/v-highlight/props-mark-class.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/packages/docs/src/examples/v-highlight/usage.vue b/packages/docs/src/examples/v-highlight/usage.vue new file mode 100644 index 00000000000..ea3e5006cf2 --- /dev/null +++ b/packages/docs/src/examples/v-highlight/usage.vue @@ -0,0 +1,42 @@ + + + diff --git a/packages/docs/src/pages/en/components/highlights.md b/packages/docs/src/pages/en/components/highlights.md new file mode 100644 index 00000000000..1f9a1e51f33 --- /dev/null +++ b/packages/docs/src/pages/en/components/highlights.md @@ -0,0 +1,80 @@ +--- +emphasized: true +meta: + nav: Highlights + title: Highlight component + description: The highlight component visually marks matching substrings within a block of text, supporting plain string queries and pre-computed match ranges. + keywords: vuetify highlight component, vue highlight component, text highlight, search highlight, mark +features: + github: /labs/VHighlight/ + label: 'C: VHighlight' + report: true +--- + +# Highlight + +The `v-highlight` component marks matching substrings within a block of text. + + + +::: warning + +This feature requires [v4.1.0](/getting-started/release-notes/?version=v4.1.0) + +::: + +## Installation + +Labs components require manual import and registration with the Vuetify instance. + +```js { resource="src/plugins/vuetify.js" } +import { VHighlight } from 'vuetify/labs/VHighlight' + +export default createVuetify({ + components: { + VHighlight, + }, +}) +``` + +## Usage + + + + + +## API + +| Component | Description | +| - | - | +| [v-highlight](/api/v-highlight/) | Primary component | + + + +## Guide + +### Props + +#### Query + +Pass a string or array of strings to **query** to be marked within text using case-insensitive matching. Multiple terms are matched independently and overlapping or adjacent ranges are merged. + + + +#### Matches + +Pass pre-computed `[start, end]` index pairs to **matches** to skip the internal search step entirely. This is useful if you need to bind fuzzy-search algorithm that responds with matching ranges. + + + +#### Mark class + +Use **mark-class** to customize the mark styling with CSS classes. + + + +### Misc + +#### Selection match + + diff --git a/packages/docs/src/pages/en/labs/introduction.md b/packages/docs/src/pages/en/labs/introduction.md index 55f4295f0c7..d71f53be447 100644 --- a/packages/docs/src/pages/en/labs/introduction.md +++ b/packages/docs/src/pages/en/labs/introduction.md @@ -84,6 +84,7 @@ The following is a list of available and up-and-coming components for use with L | [v-pie](/components/pie-charts/) | A component to display data as interactive pie/donut chart | [v3.9.3](/getting-started/release-notes/?version=v3.9.3) | | [v-avatar-group](/components/avatar-groups/) | A component to group and display multiple avatars | [v3.12.0](/getting-started/release-notes/?version=v3.12.0) | | [v-command-palette](/components/command-palettes/) | A searchable command palette component | [v3.12.0](/getting-started/release-notes/?version=v3.12.0) | +| [v-highlight](/components/highlights/) | Renders text with highlighted search matches | [vTBD](/getting-started/release-notes/?version=vTBD) | ::: warning Lab component APIs are **NOT** finalized and can and will change. You should **EXPECT** for things to break during the course of development. diff --git a/packages/vuetify/src/components/VAutocomplete/VAutocomplete.tsx b/packages/vuetify/src/components/VAutocomplete/VAutocomplete.tsx index 27e5abd3db4..108ed8b13ee 100644 --- a/packages/vuetify/src/components/VAutocomplete/VAutocomplete.tsx +++ b/packages/vuetify/src/components/VAutocomplete/VAutocomplete.tsx @@ -15,11 +15,12 @@ import { makeSelectProps } from '@/components/VSelect/VSelect' import { VSheet } from '@/components/VSheet' import { makeVTextFieldProps, VTextField } from '@/components/VTextField/VTextField' import { VVirtualScroll } from '@/components/VVirtualScroll' +import { VHighlight } from '@/labs/VHighlight' // Composables import { useScrolling } from '../VSelect/useScrolling' import { useTextColor } from '@/composables/color' -import { highlightResult, makeFilterProps, useFilter } from '@/composables/filter' +import { makeFilterProps, useFilter } from '@/composables/filter' import { useFocusGroups } from '@/composables/focusGroups' import { useForm } from '@/composables/form' import { forwardRefs } from '@/composables/forwardRefs' @@ -611,7 +612,7 @@ export const VAutocomplete = genericComponent { return isPristine.value ? item.title - : highlightResult('v-autocomplete', item.title, getMatches(item)?.title) + : }, }} diff --git a/packages/vuetify/src/components/VCombobox/VCombobox.tsx b/packages/vuetify/src/components/VCombobox/VCombobox.tsx index 554fcbbf04f..13c492a13c0 100644 --- a/packages/vuetify/src/components/VCombobox/VCombobox.tsx +++ b/packages/vuetify/src/components/VCombobox/VCombobox.tsx @@ -16,11 +16,12 @@ import { VSheet } from '@/components/VSheet' import { VTextField } from '@/components/VTextField' import { makeVTextFieldProps } from '@/components/VTextField/VTextField' import { VVirtualScroll } from '@/components/VVirtualScroll' +import { VHighlight } from '@/labs/VHighlight' // Composables import { useScrolling } from '../VSelect/useScrolling' import { useTextColor } from '@/composables/color' -import { highlightResult, makeFilterProps, useFilter } from '@/composables/filter' +import { makeFilterProps, useFilter } from '@/composables/filter' import { useFocusGroups } from '@/composables/focusGroups' import { useForm } from '@/composables/form' import { forwardRefs } from '@/composables/forwardRefs' @@ -674,7 +675,7 @@ export const VCombobox = genericComponent { return isPristine.value ? item.title - : highlightResult('v-combobox', item.title, getMatches(item)?.title) + : }, }} diff --git a/packages/vuetify/src/components/VSelect/VSelect.tsx b/packages/vuetify/src/components/VSelect/VSelect.tsx index f75be0a38cf..94c51f026c6 100644 --- a/packages/vuetify/src/components/VSelect/VSelect.tsx +++ b/packages/vuetify/src/components/VSelect/VSelect.tsx @@ -15,12 +15,13 @@ import { VMenu } from '@/components/VMenu' import { VSheet } from '@/components/VSheet' import { makeVTextFieldProps, VTextField } from '@/components/VTextField/VTextField' import { VVirtualScroll } from '@/components/VVirtualScroll' +import { VHighlight } from '@/labs/VHighlight' // Composables import { useScrolling } from './useScrolling' import { useFocusGroups } from '../../composables/focusGroups' import { useAutocomplete } from '@/composables/autocomplete' -import { highlightResult, makeFilterProps, useFilter } from '@/composables/filter' +import { makeFilterProps, useFilter } from '@/composables/filter' import { useForm } from '@/composables/form' import { forwardRefs } from '@/composables/forwardRefs' import { IconValue } from '@/composables/icons' @@ -617,7 +618,7 @@ export const VSelect = genericComponent { return search.value - ? highlightResult('v-select', item.title, getMatches(item)?.title) + ? : item.title }, }} diff --git a/packages/vuetify/src/labs/VHighlight/VHighlight.sass b/packages/vuetify/src/labs/VHighlight/VHighlight.sass new file mode 100644 index 00000000000..ec68699fdbb --- /dev/null +++ b/packages/vuetify/src/labs/VHighlight/VHighlight.sass @@ -0,0 +1,10 @@ +@use '../../styles/settings' +@use '../../styles/tools' +@use './variables' as * + +@include tools.layer('components') + .v-highlight + &__mark + background: $highlight-mark-background + border-radius: $highlight-mark-border-radius + color: $highlight-mark-color diff --git a/packages/vuetify/src/labs/VHighlight/VHighlight.tsx b/packages/vuetify/src/labs/VHighlight/VHighlight.tsx new file mode 100644 index 00000000000..02780170b79 --- /dev/null +++ b/packages/vuetify/src/labs/VHighlight/VHighlight.tsx @@ -0,0 +1,51 @@ +// Styles +import './VHighlight.sass' + +// Composables +import { useHighlight } from './highlight' +import { makeTagProps } from '@/composables/tag' + +// Utilities +import { toRef } from 'vue' +import { defineComponent, propsFactory } from '@/util' + +// Types +import type { PropType } from 'vue' +import type { FilterMatchArrayMultiple } from '@/composables/filter' + +export const makeVHighlightProps = propsFactory({ + text: { + type: String, + default: '', + }, + query: [String, Array] as PropType, + matches: Array as PropType, + markClass: String, + ...makeTagProps({ tag: 'span' }), +}, 'VHighlight') + +export const VHighlight = defineComponent({ + name: 'VHighlight', + + props: makeVHighlightProps(), + + setup (props) { + const chunks = useHighlight({ + text: toRef(props, 'text'), + query: toRef(props, 'query'), + matches: toRef(props, 'matches'), + }) + + return () => ( + + { chunks.value.map((chunk, i) => ( + chunk.match + ? { chunk.text } + : { chunk.text } + ))} + + ) + }, +}) + +export type VHighlight = InstanceType diff --git a/packages/vuetify/src/labs/VHighlight/__tests__/highlight.spec.ts b/packages/vuetify/src/labs/VHighlight/__tests__/highlight.spec.ts new file mode 100644 index 00000000000..d8f7804ac2b --- /dev/null +++ b/packages/vuetify/src/labs/VHighlight/__tests__/highlight.spec.ts @@ -0,0 +1,75 @@ +import { useHighlight } from '../highlight' + +const run = (props: Parameters[0]) => useHighlight(props).value + +describe('useHighlight', () => { + describe('with pre-computed matches', () => { + it('does 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('does 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 }, + ]) + }) + }) + + describe('with query string', () => { + it('matches case-insensitively', () => { + const chunks = run({ text: 'Hello World', query: 'HELLO' }) + expect(chunks[0]).toStrictEqual({ text: 'Hello', match: true }) + }) + + it('finds every occurrence when the query appears multiple times', () => { + expect(run({ text: 'aa bb aa', query: 'aa' }).filter(c => c.match).map(c => c.text)) + .toStrictEqual(['aa', 'aa']) + }) + + it('merges overlapping spans from multiple queries', () => { + // 'foo' → [0,3], 'oba' → [2,5]; overlap → [0,5] + expect(run({ text: 'foobar', query: ['foo', 'oba'] })).toStrictEqual([ + { text: 'fooba', match: true }, + { text: 'r', match: false }, + ]) + }) + + it('merges adjacent spans (end of one equals start of next)', () => { + // 'foo' → [0,3], 'bar' → [3,6]; adjacent → [0,6] + expect(run({ text: 'foobar', query: ['foo', 'bar'] })).toStrictEqual([ + { text: 'foobar', match: true }, + ]) + }) + + it('ignores 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', () => { + it('uses pre-computed matches over query when both are provided', () => { + // query 'hello' would highlight all, but matches says only [1,3] + expect(run({ text: 'hello', matches: [[1, 3]], query: 'hello' })).toStrictEqual([ + { text: 'h', match: false }, + { text: 'el', match: true }, + { text: 'lo', match: false }, + ]) + }) + + it('falls 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 }, + ]) + }) + }) +}) diff --git a/packages/vuetify/src/labs/VHighlight/_variables.scss b/packages/vuetify/src/labs/VHighlight/_variables.scss new file mode 100644 index 00000000000..63714c04486 --- /dev/null +++ b/packages/vuetify/src/labs/VHighlight/_variables.scss @@ -0,0 +1,4 @@ +// VHighlight +$highlight-mark-background: rgb(var(--v-theme-surface-light)) !default; +$highlight-mark-border-radius: 2px !default; +$highlight-mark-color: inherit !default; diff --git a/packages/vuetify/src/labs/VHighlight/highlight.ts b/packages/vuetify/src/labs/VHighlight/highlight.ts new file mode 100644 index 00000000000..c5a69d57498 --- /dev/null +++ b/packages/vuetify/src/labs/VHighlight/highlight.ts @@ -0,0 +1,74 @@ +// Utilities +import { computed, toValue } from 'vue' + +// Types +import type { MaybeRefOrGetter } from 'vue' +import type { FilterMatchArrayMultiple } from '@/composables/filter' + +export type HighlightChunk = { text: string, match: boolean } + +function matchesToChunks (text: string, matches: FilterMatchArrayMultiple): HighlightChunk[] { + const chunks: HighlightChunk[] = [] + let cursor = 0 + + for (const [start, end] of matches) { + 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 queryToMatches (text: string, query: string | string[]): FilterMatchArrayMultiple { + const terms = (Array.isArray(query) ? query : [query]).filter(Boolean) + const lowerText = text.toLocaleLowerCase() + const spans: [number, number][] = [] + + for (const term of terms) { + const lowerTerm = term.toLocaleLowerCase() + let i = lowerText.indexOf(lowerTerm) + + while (~i) { + spans.push([i, i + term.length]) + i = lowerText.indexOf(lowerTerm, i + term.length) + } + } + + spans.sort((a, b) => a[0] - b[0]) + + const merged: [number, number][] = [] + + 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 +} + +interface UseHighlightProps { + text: MaybeRefOrGetter + query?: MaybeRefOrGetter + matches?: MaybeRefOrGetter +} + +export function useHighlight (props: UseHighlightProps) { + return computed(() => { + const text = toValue(props.text) + const matches = toValue(props.matches) + const query = toValue(props.query) + + if (matches?.length) return matchesToChunks(text, matches) + + if (query) { + const queryMatches = queryToMatches(text, query) + return queryMatches.length ? matchesToChunks(text, queryMatches) : [{ text, match: false }] + } + + return [{ text, match: false }] + }) +} diff --git a/packages/vuetify/src/labs/VHighlight/index.ts b/packages/vuetify/src/labs/VHighlight/index.ts new file mode 100644 index 00000000000..2974a96e6a6 --- /dev/null +++ b/packages/vuetify/src/labs/VHighlight/index.ts @@ -0,0 +1 @@ +export { VHighlight } from './VHighlight' diff --git a/packages/vuetify/src/labs/components.ts b/packages/vuetify/src/labs/components.ts index b5b267afa8a..ade466141b3 100644 --- a/packages/vuetify/src/labs/components.ts +++ b/packages/vuetify/src/labs/components.ts @@ -3,6 +3,7 @@ export * from './VColorInput' export * from './VCommandPalette' export * from './VDateInput' export * from './VFileUpload' +export * from './VHighlight' export * from './VIconBtn' export * from './VMaskInput' export * from './VPicker' From 778b573a1f3afa872bb870546a33881d5f1aaca3 Mon Sep 17 00:00:00 2001 From: J-Sek Date: Wed, 29 Apr 2026 13:43:26 +0200 Subject: [PATCH 2/8] feat: add `match-all` and `ignore-case` --- .../src/locale/en/VHighlight.json | 9 ++++++ .../v-highlight/misc-selection-match.vue | 2 ++ .../docs/src/examples/v-highlight/usage.vue | 9 ++++++ .../src/pages/en/components/highlights.md | 2 +- .../VAutocomplete/VAutocomplete.tsx | 2 +- .../src/components/VCombobox/VCombobox.tsx | 2 +- .../src/components/VSelect/VSelect.tsx | 2 +- .../src/labs/VHighlight/VHighlight.tsx | 4 +++ .../VHighlight/__tests__/highlight.spec.ts | 30 +++++++++++++++++-- .../vuetify/src/labs/VHighlight/highlight.ts | 19 +++++++----- 10 files changed, 67 insertions(+), 14 deletions(-) create mode 100644 packages/api-generator/src/locale/en/VHighlight.json diff --git a/packages/api-generator/src/locale/en/VHighlight.json b/packages/api-generator/src/locale/en/VHighlight.json new file mode 100644 index 00000000000..0a85830f5cb --- /dev/null +++ b/packages/api-generator/src/locale/en/VHighlight.json @@ -0,0 +1,9 @@ +{ + "props": { + "query": "The search string or array of strings to highlight within the text.", + "matches": "Pre-computed match ranges as `[start, end]` pairs. Takes priority over **query** when provided and non-empty.", + "matchAll": "When enabled, all occurrences of each query term are highlighted. When disabled, only the first occurrence of each term is highlighted.", + "ignoreCase": "When enabled, matching is case-insensitive.", + "markClass": "Additional CSS class(es) applied to each `` element wrapping a highlighted match." + } +} diff --git a/packages/docs/src/examples/v-highlight/misc-selection-match.vue b/packages/docs/src/examples/v-highlight/misc-selection-match.vue index 72fa588acdf..7b18f8b6570 100644 --- a/packages/docs/src/examples/v-highlight/misc-selection-match.vue +++ b/packages/docs/src/examples/v-highlight/misc-selection-match.vue @@ -9,6 +9,8 @@ :text="text" class="selection-target text-body-2 pa-4 rounded border" tag="pre" + ignore-case + match-all @mouseup="onMouseUp" > diff --git a/packages/docs/src/examples/v-highlight/usage.vue b/packages/docs/src/examples/v-highlight/usage.vue index ea3e5006cf2..fbc3333dcb9 100644 --- a/packages/docs/src/examples/v-highlight/usage.vue +++ b/packages/docs/src/examples/v-highlight/usage.vue @@ -21,6 +21,11 @@ class="text-body-1" > + + @@ -29,12 +34,16 @@ const model = shallowRef('default') const options = [] const query = shallowRef('framework') + const matchAll = shallowRef(false) + const ignoreCase = shallowRef(false) const text = 'Vue is a progressive JavaScript framework for building user interfaces. Unlike monolithic frameworks, Vue is designed to be incrementally adoptable. The core library focuses on the view layer only, making it easy to integrate with other libraries. Thousands of companies use Vue in production today.' const props = computed(() => ({ text, query: query.value || undefined, + matchAll: matchAll.value || undefined, + ignoreCase: ignoreCase.value || undefined, tag: 'p', })) diff --git a/packages/docs/src/pages/en/components/highlights.md b/packages/docs/src/pages/en/components/highlights.md index 1f9a1e51f33..b7533d4a4ba 100644 --- a/packages/docs/src/pages/en/components/highlights.md +++ b/packages/docs/src/pages/en/components/highlights.md @@ -57,7 +57,7 @@ export default createVuetify({ #### Query -Pass a string or array of strings to **query** to be marked within text using case-insensitive matching. Multiple terms are matched independently and overlapping or adjacent ranges are merged. +Pass a string or array of strings to **query** to be marked. Multiple terms are matched independently and overlapping or adjacent ranges are merged. diff --git a/packages/vuetify/src/components/VAutocomplete/VAutocomplete.tsx b/packages/vuetify/src/components/VAutocomplete/VAutocomplete.tsx index 108ed8b13ee..53fa19b9aca 100644 --- a/packages/vuetify/src/components/VAutocomplete/VAutocomplete.tsx +++ b/packages/vuetify/src/components/VAutocomplete/VAutocomplete.tsx @@ -612,7 +612,7 @@ export const VAutocomplete = genericComponent { return isPristine.value ? item.title - : + : }, }} diff --git a/packages/vuetify/src/components/VCombobox/VCombobox.tsx b/packages/vuetify/src/components/VCombobox/VCombobox.tsx index 13c492a13c0..90cfd7600eb 100644 --- a/packages/vuetify/src/components/VCombobox/VCombobox.tsx +++ b/packages/vuetify/src/components/VCombobox/VCombobox.tsx @@ -675,7 +675,7 @@ export const VCombobox = genericComponent { return isPristine.value ? item.title - : + : }, }} diff --git a/packages/vuetify/src/components/VSelect/VSelect.tsx b/packages/vuetify/src/components/VSelect/VSelect.tsx index 94c51f026c6..090e10e1c65 100644 --- a/packages/vuetify/src/components/VSelect/VSelect.tsx +++ b/packages/vuetify/src/components/VSelect/VSelect.tsx @@ -618,7 +618,7 @@ export const VSelect = genericComponent { return search.value - ? + ? : item.title }, }} diff --git a/packages/vuetify/src/labs/VHighlight/VHighlight.tsx b/packages/vuetify/src/labs/VHighlight/VHighlight.tsx index 02780170b79..d5c3cb6fdb3 100644 --- a/packages/vuetify/src/labs/VHighlight/VHighlight.tsx +++ b/packages/vuetify/src/labs/VHighlight/VHighlight.tsx @@ -20,6 +20,8 @@ export const makeVHighlightProps = propsFactory({ }, query: [String, Array] as PropType, matches: Array as PropType, + matchAll: Boolean, + ignoreCase: Boolean, markClass: String, ...makeTagProps({ tag: 'span' }), }, 'VHighlight') @@ -34,6 +36,8 @@ export const VHighlight = defineComponent({ text: toRef(props, 'text'), query: toRef(props, 'query'), matches: toRef(props, 'matches'), + matchAll: toRef(props, 'matchAll'), + ignoreCase: toRef(props, 'ignoreCase'), }) return () => ( diff --git a/packages/vuetify/src/labs/VHighlight/__tests__/highlight.spec.ts b/packages/vuetify/src/labs/VHighlight/__tests__/highlight.spec.ts index d8f7804ac2b..c37ce35a7e8 100644 --- a/packages/vuetify/src/labs/VHighlight/__tests__/highlight.spec.ts +++ b/packages/vuetify/src/labs/VHighlight/__tests__/highlight.spec.ts @@ -20,13 +20,19 @@ describe('useHighlight', () => { }) describe('with query string', () => { - it('matches case-insensitively', () => { - const chunks = run({ text: 'Hello World', query: 'HELLO' }) + it('is case-sensitive by default', () => { + expect(run({ text: 'Hello World', query: 'HELLO' })).toStrictEqual([ + { text: 'Hello World', match: false }, + ]) + }) + + it('matches case-insensitively when ignoreCase is true', () => { + const chunks = run({ text: 'Hello World', query: 'HELLO', ignoreCase: true }) expect(chunks[0]).toStrictEqual({ text: 'Hello', match: true }) }) it('finds every occurrence when the query appears multiple times', () => { - expect(run({ text: 'aa bb aa', query: 'aa' }).filter(c => c.match).map(c => c.text)) + expect(run({ text: 'aa bb aa', query: 'aa', matchAll: true }).filter(c => c.match).map(c => c.text)) .toStrictEqual(['aa', 'aa']) }) @@ -54,6 +60,24 @@ describe('useHighlight', () => { }) }) + describe('matchAll: false', () => { + it('marks only the first occurrence of a single query', () => { + expect(run({ text: 'aa bb aa', query: 'aa', matchAll: false })).toStrictEqual([ + { text: 'aa', match: true }, + { text: ' bb aa', match: false }, + ]) + }) + + it('marks only the first occurrence per term in an array query', () => { + expect(run({ text: 'aa bb aa cc aa', query: ['aa', 'cc'], matchAll: false })).toStrictEqual([ + { text: 'aa', match: true }, + { text: ' bb aa ', match: false }, + { text: 'cc', match: true }, + { text: ' aa', match: false }, + ]) + }) + }) + describe('priority', () => { it('uses pre-computed matches over query when both are provided', () => { // query 'hello' would highlight all, but matches says only [1,3] diff --git a/packages/vuetify/src/labs/VHighlight/highlight.ts b/packages/vuetify/src/labs/VHighlight/highlight.ts index c5a69d57498..ebee08e9297 100644 --- a/packages/vuetify/src/labs/VHighlight/highlight.ts +++ b/packages/vuetify/src/labs/VHighlight/highlight.ts @@ -22,18 +22,19 @@ function matchesToChunks (text: string, matches: FilterMatchArrayMultiple): High return chunks } -function queryToMatches (text: string, query: string | string[]): FilterMatchArrayMultiple { +function queryToMatches (text: string, query: string | string[], matchAll: boolean, ignoreCase: boolean): FilterMatchArrayMultiple { const terms = (Array.isArray(query) ? query : [query]).filter(Boolean) - const lowerText = text.toLocaleLowerCase() + const haystack = ignoreCase ? text.toLocaleLowerCase() : text const spans: [number, number][] = [] for (const term of terms) { - const lowerTerm = term.toLocaleLowerCase() - let i = lowerText.indexOf(lowerTerm) + const needle = ignoreCase ? term.toLocaleLowerCase() : term + let i = haystack.indexOf(needle) while (~i) { spans.push([i, i + term.length]) - i = lowerText.indexOf(lowerTerm, i + term.length) + if (!matchAll) break + i = haystack.indexOf(needle, i + term.length) } } @@ -54,6 +55,8 @@ interface UseHighlightProps { text: MaybeRefOrGetter query?: MaybeRefOrGetter matches?: MaybeRefOrGetter + matchAll?: MaybeRefOrGetter + ignoreCase?: MaybeRefOrGetter } export function useHighlight (props: UseHighlightProps) { @@ -61,11 +64,13 @@ export function useHighlight (props: UseHighlightProps) { const text = toValue(props.text) const matches = toValue(props.matches) const query = toValue(props.query) + const matchAll = toValue(props.matchAll) ?? false + const ignoreCase = toValue(props.ignoreCase) ?? false - if (matches?.length) return matchesToChunks(text, matches) + if (matches?.length) return matchesToChunks(text, matchAll ? matches : matches.slice(0, 1)) if (query) { - const queryMatches = queryToMatches(text, query) + const queryMatches = queryToMatches(text, query, matchAll, ignoreCase) return queryMatches.length ? matchesToChunks(text, queryMatches) : [{ text, match: false }] } From f96b877dc638e0d3472ff1a7dc1f8650ce955065 Mon Sep 17 00:00:00 2001 From: J-Sek Date: Wed, 29 Apr 2026 18:30:35 +0200 Subject: [PATCH 3/8] fix: ignore `match-all` when highlighting `matches` --- packages/api-generator/src/locale/en/VHighlight.json | 2 +- .../src/labs/VHighlight/__tests__/highlight.spec.ts | 10 ++++++++++ packages/vuetify/src/labs/VHighlight/highlight.ts | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/api-generator/src/locale/en/VHighlight.json b/packages/api-generator/src/locale/en/VHighlight.json index 0a85830f5cb..f6039997f95 100644 --- a/packages/api-generator/src/locale/en/VHighlight.json +++ b/packages/api-generator/src/locale/en/VHighlight.json @@ -2,7 +2,7 @@ "props": { "query": "The search string or array of strings to highlight within the text.", "matches": "Pre-computed match ranges as `[start, end]` pairs. Takes priority over **query** when provided and non-empty.", - "matchAll": "When enabled, all occurrences of each query term are highlighted. When disabled, only the first occurrence of each term is highlighted.", + "matchAll": "When enabled, all occurrences of each query term are highlighted. When disabled, only the first occurrence of each term is highlighted. Has no effect when **matches** is provided.", "ignoreCase": "When enabled, matching is case-insensitive.", "markClass": "Additional CSS class(es) applied to each `` element wrapping a highlighted match." } diff --git a/packages/vuetify/src/labs/VHighlight/__tests__/highlight.spec.ts b/packages/vuetify/src/labs/VHighlight/__tests__/highlight.spec.ts index c37ce35a7e8..c6f68dc205d 100644 --- a/packages/vuetify/src/labs/VHighlight/__tests__/highlight.spec.ts +++ b/packages/vuetify/src/labs/VHighlight/__tests__/highlight.spec.ts @@ -78,6 +78,16 @@ describe('useHighlight', () => { }) }) + describe('matchAll is ignored when matches are pre-computed', () => { + it('renders all provided spans regardless of matchAll: false', () => { + expect(run({ text: 'aa bb aa', matches: [[0, 2], [6, 8]], matchAll: false })).toStrictEqual([ + { text: 'aa', match: true }, + { text: ' bb ', match: false }, + { text: 'aa', match: true }, + ]) + }) + }) + describe('priority', () => { it('uses pre-computed matches over query when both are provided', () => { // query 'hello' would highlight all, but matches says only [1,3] diff --git a/packages/vuetify/src/labs/VHighlight/highlight.ts b/packages/vuetify/src/labs/VHighlight/highlight.ts index ebee08e9297..bb3543f30fe 100644 --- a/packages/vuetify/src/labs/VHighlight/highlight.ts +++ b/packages/vuetify/src/labs/VHighlight/highlight.ts @@ -67,7 +67,7 @@ export function useHighlight (props: UseHighlightProps) { const matchAll = toValue(props.matchAll) ?? false const ignoreCase = toValue(props.ignoreCase) ?? false - if (matches?.length) return matchesToChunks(text, matchAll ? matches : matches.slice(0, 1)) + if (matches?.length) return matchesToChunks(text, matches) if (query) { const queryMatches = queryToMatches(text, query, matchAll, ignoreCase) From ae58cd30c2eee7904036bbbd8914c2746ef353e2 Mon Sep 17 00:00:00 2001 From: J-Sek Date: Wed, 29 Apr 2026 18:31:47 +0200 Subject: [PATCH 4/8] refactor: readable condition in while loop --- packages/vuetify/src/labs/VHighlight/highlight.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/vuetify/src/labs/VHighlight/highlight.ts b/packages/vuetify/src/labs/VHighlight/highlight.ts index bb3543f30fe..8eae17da4f2 100644 --- a/packages/vuetify/src/labs/VHighlight/highlight.ts +++ b/packages/vuetify/src/labs/VHighlight/highlight.ts @@ -29,12 +29,12 @@ function queryToMatches (text: string, query: string | string[], matchAll: boole for (const term of terms) { const needle = ignoreCase ? term.toLocaleLowerCase() : term - let i = haystack.indexOf(needle) + let index = haystack.indexOf(needle) - while (~i) { - spans.push([i, i + term.length]) + while (index !== -1) { + spans.push([index, index + term.length]) if (!matchAll) break - i = haystack.indexOf(needle, i + term.length) + index = haystack.indexOf(needle, index + term.length) } } From 4f9802c2355d6dc6f863c677b3c81d056177b596 Mon Sep 17 00:00:00 2001 From: J-Sek Date: Thu, 14 May 2026 15:10:41 +0200 Subject: [PATCH 5/8] feat: color, opacity --- .../src/locale/en/VHighlight.json | 2 + .../v-highlight/misc-css-variables.vue | 53 +++++++++++++++++++ .../v-highlight/misc-selection-match.vue | 2 +- .../docs/src/examples/v-highlight/usage.vue | 4 ++ .../src/pages/en/components/highlights.md | 6 +++ .../__snapshots__/theme.spec.ts.snap | 9 ++++ packages/vuetify/src/composables/theme.ts | 2 + .../src/labs/VHighlight/VHighlight.sass | 6 +-- .../src/labs/VHighlight/VHighlight.tsx | 18 ++++++- .../src/labs/VHighlight/_variables.scss | 7 +-- 10 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 packages/docs/src/examples/v-highlight/misc-css-variables.vue diff --git a/packages/api-generator/src/locale/en/VHighlight.json b/packages/api-generator/src/locale/en/VHighlight.json index f6039997f95..46ea64b0d8f 100644 --- a/packages/api-generator/src/locale/en/VHighlight.json +++ b/packages/api-generator/src/locale/en/VHighlight.json @@ -4,6 +4,8 @@ "matches": "Pre-computed match ranges as `[start, end]` pairs. Takes priority over **query** when provided and non-empty.", "matchAll": "When enabled, all occurrences of each query term are highlighted. When disabled, only the first occurrence of each term is highlighted. Has no effect when **matches** is provided.", "ignoreCase": "When enabled, matching is case-insensitive.", + "color": "Applies a theme or CSS color to highlighted matches. The background is derived by mixing this color with the theme's highlight opacity.", + "opacity": "Overrides the background opacity of highlighted matches. Accepts only CSS `` - use `30%` instead of `0.3`.", "markClass": "Additional CSS class(es) applied to each `` element wrapping a highlighted match." } } diff --git a/packages/docs/src/examples/v-highlight/misc-css-variables.vue b/packages/docs/src/examples/v-highlight/misc-css-variables.vue new file mode 100644 index 00000000000..b11d7ec6a4d --- /dev/null +++ b/packages/docs/src/examples/v-highlight/misc-css-variables.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/packages/docs/src/examples/v-highlight/misc-selection-match.vue b/packages/docs/src/examples/v-highlight/misc-selection-match.vue index 7b18f8b6570..3236e878203 100644 --- a/packages/docs/src/examples/v-highlight/misc-selection-match.vue +++ b/packages/docs/src/examples/v-highlight/misc-selection-match.vue @@ -7,7 +7,7 @@ @@ -36,6 +37,8 @@ const query = shallowRef('framework') const matchAll = shallowRef(false) const ignoreCase = shallowRef(false) + const color = shallowRef() + const colors = ['primary', '#ac46ff', 'orange-darken-2'] const text = 'Vue is a progressive JavaScript framework for building user interfaces. Unlike monolithic frameworks, Vue is designed to be incrementally adoptable. The core library focuses on the view layer only, making it easy to integrate with other libraries. Thousands of companies use Vue in production today.' @@ -44,6 +47,7 @@ query: query.value || undefined, matchAll: matchAll.value || undefined, ignoreCase: ignoreCase.value || undefined, + color: color.value || undefined, tag: 'p', })) diff --git a/packages/docs/src/pages/en/components/highlights.md b/packages/docs/src/pages/en/components/highlights.md index b7533d4a4ba..c95a0e019ac 100644 --- a/packages/docs/src/pages/en/components/highlights.md +++ b/packages/docs/src/pages/en/components/highlights.md @@ -75,6 +75,12 @@ Use **mark-class** to customize the mark styling with CSS classes. ### Misc +#### CSS variables + +Override `--v-highlight-background`, `--v-highlight-color`, and `--v-highlight-border-radius` on any ancestor to restyle marks without touching the component. + + + #### Selection match diff --git a/packages/vuetify/src/composables/__tests__/__snapshots__/theme.spec.ts.snap b/packages/vuetify/src/composables/__tests__/__snapshots__/theme.spec.ts.snap index 4b36b79b79c..89fdf6e8190 100644 --- a/packages/vuetify/src/composables/__tests__/__snapshots__/theme.spec.ts.snap +++ b/packages/vuetify/src/composables/__tests__/__snapshots__/theme.spec.ts.snap @@ -69,6 +69,7 @@ exports[`createTheme > should allow for themes to be scoped 1`] = ` --v-theme-on-light: 0, 0, 0; --v-elevation-overlay-color: black; --v-elevation-overlay-opacity-step: 2%; + --v-highlight-opacity: 8%; } .v-theme--light { color-scheme: normal; @@ -132,6 +133,7 @@ exports[`createTheme > should allow for themes to be scoped 1`] = ` --v-theme-on-light: 0, 0, 0; --v-elevation-overlay-color: black; --v-elevation-overlay-opacity-step: 2%; + --v-highlight-opacity: 8%; } .v-theme--dark { color-scheme: dark; @@ -195,6 +197,7 @@ exports[`createTheme > should allow for themes to be scoped 1`] = ` --v-theme-on-light: 0, 0, 0; --v-elevation-overlay-color: white; --v-elevation-overlay-opacity-step: 2%; + --v-highlight-opacity: 20%; } } @layer theme-background { @@ -453,6 +456,7 @@ exports[`createTheme > should allow for themes to be scoped 1`] = ` --v-theme-on-light: 0, 0, 0; --v-elevation-overlay-color: black; --v-elevation-overlay-opacity-step: 2%; + --v-highlight-opacity: 8%; } :where(#my-app) .v-theme--light { color-scheme: normal; @@ -516,6 +520,7 @@ exports[`createTheme > should allow for themes to be scoped 1`] = ` --v-theme-on-light: 0, 0, 0; --v-elevation-overlay-color: black; --v-elevation-overlay-opacity-step: 2%; + --v-highlight-opacity: 8%; } :where(#my-app) .v-theme--dark { color-scheme: dark; @@ -579,6 +584,7 @@ exports[`createTheme > should allow for themes to be scoped 1`] = ` --v-theme-on-light: 0, 0, 0; --v-elevation-overlay-color: white; --v-elevation-overlay-opacity-step: 2%; + --v-highlight-opacity: 20%; } } @layer theme-background { @@ -842,6 +848,7 @@ exports[`createTheme > should create style element 1`] = ` --v-theme-on-light: 0, 0, 0; --v-elevation-overlay-color: black; --v-elevation-overlay-opacity-step: 2%; + --v-highlight-opacity: 8%; } .v-theme--light { color-scheme: normal; @@ -905,6 +912,7 @@ exports[`createTheme > should create style element 1`] = ` --v-theme-on-light: 0, 0, 0; --v-elevation-overlay-color: black; --v-elevation-overlay-opacity-step: 2%; + --v-highlight-opacity: 8%; } .v-theme--dark { color-scheme: dark; @@ -968,6 +976,7 @@ exports[`createTheme > should create style element 1`] = ` --v-theme-on-light: 0, 0, 0; --v-elevation-overlay-color: white; --v-elevation-overlay-opacity-step: 2%; + --v-highlight-opacity: 20%; } } @layer theme-background { diff --git a/packages/vuetify/src/composables/theme.ts b/packages/vuetify/src/composables/theme.ts index 4e764dd15bb..6cc3936a697 100644 --- a/packages/vuetify/src/composables/theme.ts +++ b/packages/vuetify/src/composables/theme.ts @@ -173,6 +173,7 @@ function genDefaults () { 'theme-on-light': '#000', 'elevation-overlay-color': 'black', 'elevation-overlay-opacity-step': '2%', + 'highlight-opacity': '8%', }, }, dark: { @@ -215,6 +216,7 @@ function genDefaults () { 'theme-on-light': '#000', 'elevation-overlay-color': 'white', 'elevation-overlay-opacity-step': '2%', + 'highlight-opacity': '20%', }, }, }, diff --git a/packages/vuetify/src/labs/VHighlight/VHighlight.sass b/packages/vuetify/src/labs/VHighlight/VHighlight.sass index ec68699fdbb..8f4ecda824a 100644 --- a/packages/vuetify/src/labs/VHighlight/VHighlight.sass +++ b/packages/vuetify/src/labs/VHighlight/VHighlight.sass @@ -5,6 +5,6 @@ @include tools.layer('components') .v-highlight &__mark - background: $highlight-mark-background - border-radius: $highlight-mark-border-radius - color: $highlight-mark-color + background: var(--v-highlight-background, #{$highlight-background}) + border-radius: var(--v-highlight-border-radius, #{$highlight-border-radius}) + color: var(--v-highlight-color, #{$highlight-color}) diff --git a/packages/vuetify/src/labs/VHighlight/VHighlight.tsx b/packages/vuetify/src/labs/VHighlight/VHighlight.tsx index d5c3cb6fdb3..1608de9cbbb 100644 --- a/packages/vuetify/src/labs/VHighlight/VHighlight.tsx +++ b/packages/vuetify/src/labs/VHighlight/VHighlight.tsx @@ -3,6 +3,7 @@ import './VHighlight.sass' // Composables import { useHighlight } from './highlight' +import { useTextColor } from '@/composables/color' import { makeTagProps } from '@/composables/tag' // Utilities @@ -22,6 +23,8 @@ export const makeVHighlightProps = propsFactory({ matches: Array as PropType, matchAll: Boolean, ignoreCase: Boolean, + color: String, + opacity: [String, Number], markClass: String, ...makeTagProps({ tag: 'span' }), }, 'VHighlight') @@ -40,11 +43,24 @@ export const VHighlight = defineComponent({ ignoreCase: toRef(props, 'ignoreCase'), }) + const { textColorClasses, textColorStyles } = useTextColor(() => props.color) + return () => ( { chunks.value.map((chunk, i) => ( chunk.match - ? { chunk.text } + ? ( + + { chunk.text } + + ) : { chunk.text } ))} diff --git a/packages/vuetify/src/labs/VHighlight/_variables.scss b/packages/vuetify/src/labs/VHighlight/_variables.scss index 63714c04486..2fa685feea0 100644 --- a/packages/vuetify/src/labs/VHighlight/_variables.scss +++ b/packages/vuetify/src/labs/VHighlight/_variables.scss @@ -1,4 +1,5 @@ // VHighlight -$highlight-mark-background: rgb(var(--v-theme-surface-light)) !default; -$highlight-mark-border-radius: 2px !default; -$highlight-mark-color: inherit !default; +$highlight-opacity: 8% !default; +$highlight-background: color-mix(in srgb, currentColor var(--v-highlight-opacity, $highlight-opacity), transparent) !default; +$highlight-border-radius: 2px !default; +$highlight-color: inherit !default; From 23bcbdc2d0a0af58c505816d9114d21b0ab17d9b Mon Sep 17 00:00:00 2001 From: J-Sek Date: Thu, 14 May 2026 16:32:07 +0200 Subject: [PATCH 6/8] refactor: pure utility instead of composable for smooth v0 integration --- .../src/labs/VHighlight/VHighlight.tsx | 20 ++-- ...{highlight.spec.ts => toHighlight.spec.ts} | 76 ++++++++------- .../vuetify/src/labs/VHighlight/highlight.ts | 79 --------------- .../src/labs/VHighlight/toHighlight.ts | 96 +++++++++++++++++++ 4 files changed, 146 insertions(+), 125 deletions(-) rename packages/vuetify/src/labs/VHighlight/__tests__/{highlight.spec.ts => toHighlight.spec.ts} (50%) delete mode 100644 packages/vuetify/src/labs/VHighlight/highlight.ts create mode 100644 packages/vuetify/src/labs/VHighlight/toHighlight.ts diff --git a/packages/vuetify/src/labs/VHighlight/VHighlight.tsx b/packages/vuetify/src/labs/VHighlight/VHighlight.tsx index 1608de9cbbb..a595207168b 100644 --- a/packages/vuetify/src/labs/VHighlight/VHighlight.tsx +++ b/packages/vuetify/src/labs/VHighlight/VHighlight.tsx @@ -2,12 +2,12 @@ import './VHighlight.sass' // Composables -import { useHighlight } from './highlight' import { useTextColor } from '@/composables/color' import { makeTagProps } from '@/composables/tag' // Utilities -import { toRef } from 'vue' +import { computed } from 'vue' +import { toHighlight } from './toHighlight' import { defineComponent, propsFactory } from '@/util' // Types @@ -35,13 +35,15 @@ export const VHighlight = defineComponent({ props: makeVHighlightProps(), setup (props) { - const chunks = useHighlight({ - text: toRef(props, 'text'), - query: toRef(props, 'query'), - matches: toRef(props, 'matches'), - matchAll: toRef(props, 'matchAll'), - ignoreCase: toRef(props, 'ignoreCase'), - }) + const chunks = computed(() => toHighlight( + () => props.text, + () => props.query, + { + matches: () => props.matches, + matchAll: () => props.matchAll, + ignoreCase: () => props.ignoreCase, + }, + )) const { textColorClasses, textColorStyles } = useTextColor(() => props.color) diff --git a/packages/vuetify/src/labs/VHighlight/__tests__/highlight.spec.ts b/packages/vuetify/src/labs/VHighlight/__tests__/toHighlight.spec.ts similarity index 50% rename from packages/vuetify/src/labs/VHighlight/__tests__/highlight.spec.ts rename to packages/vuetify/src/labs/VHighlight/__tests__/toHighlight.spec.ts index c6f68dc205d..51c81dd0b42 100644 --- a/packages/vuetify/src/labs/VHighlight/__tests__/highlight.spec.ts +++ b/packages/vuetify/src/labs/VHighlight/__tests__/toHighlight.spec.ts @@ -1,58 +1,79 @@ -import { useHighlight } from '../highlight' +import { toHighlight } from '../toHighlight' -const run = (props: Parameters[0]) => useHighlight(props).value - -describe('useHighlight', () => { +describe('toHighlight', () => { describe('with pre-computed matches', () => { it('does 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('does 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('sorts 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('merges caller-supplied matches that overlap', () => { + expect(toHighlight('foobar', undefined, { matches: [[0, 4], [2, 6]] })).toStrictEqual([ + { text: 'foobar', match: true }, + ]) + }) + + it('drops inverted ranges where start >= end', () => { + expect(toHighlight('foobar', undefined, { matches: [[5, 3]] })).toStrictEqual([ + { text: 'foobar', match: false }, + ]) + }) }) describe('with query string', () => { it('is case-sensitive by default', () => { - expect(run({ text: 'Hello World', query: 'HELLO' })).toStrictEqual([ + expect(toHighlight('Hello World', 'HELLO')).toStrictEqual([ { text: 'Hello World', match: false }, ]) }) it('matches case-insensitively when ignoreCase is true', () => { - const chunks = run({ text: 'Hello World', query: 'HELLO', ignoreCase: true }) + const chunks = toHighlight('Hello World', 'HELLO', { ignoreCase: true }) expect(chunks[0]).toStrictEqual({ text: 'Hello', match: true }) }) - it('finds every occurrence when the query appears multiple times', () => { - expect(run({ text: 'aa bb aa', query: 'aa', matchAll: true }).filter(c => c.match).map(c => c.text)) + it('finds only the first occurrence by default', () => { + expect(toHighlight('aa bb aa', 'aa').filter(c => c.match).map(c => c.text)) + .toStrictEqual(['aa']) + }) + + it('finds 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('merges overlapping spans from multiple queries', () => { - // 'foo' → [0,3], 'oba' → [2,5]; overlap → [0,5] - expect(run({ text: 'foobar', query: ['foo', 'oba'] })).toStrictEqual([ + expect(toHighlight('foobar', ['foo', 'oba'])).toStrictEqual([ { text: 'fooba', match: true }, { text: 'r', match: false }, ]) }) it('merges adjacent spans (end of one equals start of next)', () => { - // 'foo' → [0,3], 'bar' → [3,6]; adjacent → [0,6] - expect(run({ text: 'foobar', query: ['foo', 'bar'] })).toStrictEqual([ + expect(toHighlight('foobar', ['foo', 'bar'])).toStrictEqual([ { text: 'foobar', match: true }, ]) }) it('ignores 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 }, @@ -60,27 +81,9 @@ describe('useHighlight', () => { }) }) - describe('matchAll: false', () => { - it('marks only the first occurrence of a single query', () => { - expect(run({ text: 'aa bb aa', query: 'aa', matchAll: false })).toStrictEqual([ - { text: 'aa', match: true }, - { text: ' bb aa', match: false }, - ]) - }) - - it('marks only the first occurrence per term in an array query', () => { - expect(run({ text: 'aa bb aa cc aa', query: ['aa', 'cc'], matchAll: false })).toStrictEqual([ - { text: 'aa', match: true }, - { text: ' bb aa ', match: false }, - { text: 'cc', match: true }, - { text: ' aa', match: false }, - ]) - }) - }) - describe('matchAll is ignored when matches are pre-computed', () => { it('renders all provided spans regardless of matchAll: false', () => { - expect(run({ text: 'aa bb aa', matches: [[0, 2], [6, 8]], matchAll: false })).toStrictEqual([ + expect(toHighlight('aa bb aa', undefined, { matches: [[0, 2], [6, 8]], matchAll: false })).toStrictEqual([ { text: 'aa', match: true }, { text: ' bb ', match: false }, { text: 'aa', match: true }, @@ -90,8 +93,7 @@ describe('useHighlight', () => { describe('priority', () => { it('uses pre-computed matches over query when both are provided', () => { - // query 'hello' would highlight all, but matches says only [1,3] - 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 }, @@ -99,7 +101,7 @@ describe('useHighlight', () => { }) it('falls 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 }, diff --git a/packages/vuetify/src/labs/VHighlight/highlight.ts b/packages/vuetify/src/labs/VHighlight/highlight.ts deleted file mode 100644 index 8eae17da4f2..00000000000 --- a/packages/vuetify/src/labs/VHighlight/highlight.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Utilities -import { computed, toValue } from 'vue' - -// Types -import type { MaybeRefOrGetter } from 'vue' -import type { FilterMatchArrayMultiple } from '@/composables/filter' - -export type HighlightChunk = { text: string, match: boolean } - -function matchesToChunks (text: string, matches: FilterMatchArrayMultiple): HighlightChunk[] { - const chunks: HighlightChunk[] = [] - let cursor = 0 - - for (const [start, end] of matches) { - 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 queryToMatches (text: string, query: string | string[], matchAll: boolean, ignoreCase: boolean): FilterMatchArrayMultiple { - const terms = (Array.isArray(query) ? query : [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) - - while (index !== -1) { - spans.push([index, index + term.length]) - if (!matchAll) break - index = haystack.indexOf(needle, index + term.length) - } - } - - spans.sort((a, b) => a[0] - b[0]) - - const merged: [number, number][] = [] - - 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 -} - -interface UseHighlightProps { - text: MaybeRefOrGetter - query?: MaybeRefOrGetter - matches?: MaybeRefOrGetter - matchAll?: MaybeRefOrGetter - ignoreCase?: MaybeRefOrGetter -} - -export function useHighlight (props: UseHighlightProps) { - return computed(() => { - const text = toValue(props.text) - const matches = toValue(props.matches) - const query = toValue(props.query) - const matchAll = toValue(props.matchAll) ?? false - const ignoreCase = toValue(props.ignoreCase) ?? false - - if (matches?.length) return matchesToChunks(text, matches) - - if (query) { - const queryMatches = queryToMatches(text, query, matchAll, ignoreCase) - return queryMatches.length ? matchesToChunks(text, queryMatches) : [{ text, match: false }] - } - - return [{ text, match: false }] - }) -} diff --git a/packages/vuetify/src/labs/VHighlight/toHighlight.ts b/packages/vuetify/src/labs/VHighlight/toHighlight.ts new file mode 100644 index 00000000000..5e5a1475d4c --- /dev/null +++ b/packages/vuetify/src/labs/VHighlight/toHighlight.ts @@ -0,0 +1,96 @@ +// Utilities +import { toValue } from 'vue' +import { wrapInArray } from '@/util' + +// Types +import type { MaybeRefOrGetter } from 'vue' + +export type MatchRange = readonly [number, number] + +export interface HighlightChunk { + text: string + match: boolean +} + +export interface ToHighlightOptions { + matches?: MaybeRefOrGetter + matchAll?: MaybeRefOrGetter + 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 = wrapInArray(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) +} + +// mirror of `toHighlight` from `@vuetify/v0` +// temporary shim, to be replaced in v5.0 +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 }] +} From 594e9d881ecf2a1c1a81663a052cc6939df49706 Mon Sep 17 00:00:00 2001 From: J-Sek Date: Thu, 14 May 2026 18:37:03 +0200 Subject: [PATCH 7/8] chore: classes for backward compatibility --- .../src/components/VAutocomplete/VAutocomplete.tsx | 10 +++++++++- .../vuetify/src/components/VCombobox/VCombobox.tsx | 10 +++++++++- packages/vuetify/src/components/VSelect/VSelect.tsx | 10 +++++++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/vuetify/src/components/VAutocomplete/VAutocomplete.tsx b/packages/vuetify/src/components/VAutocomplete/VAutocomplete.tsx index 53fa19b9aca..7fcc3d2282b 100644 --- a/packages/vuetify/src/components/VAutocomplete/VAutocomplete.tsx +++ b/packages/vuetify/src/components/VAutocomplete/VAutocomplete.tsx @@ -612,7 +612,15 @@ export const VAutocomplete = genericComponent { return isPristine.value ? item.title - : + : ( + + ) }, }} diff --git a/packages/vuetify/src/components/VCombobox/VCombobox.tsx b/packages/vuetify/src/components/VCombobox/VCombobox.tsx index 90cfd7600eb..9cae645b34c 100644 --- a/packages/vuetify/src/components/VCombobox/VCombobox.tsx +++ b/packages/vuetify/src/components/VCombobox/VCombobox.tsx @@ -675,7 +675,15 @@ export const VCombobox = genericComponent { return isPristine.value ? item.title - : + : ( + + ) }, }} diff --git a/packages/vuetify/src/components/VSelect/VSelect.tsx b/packages/vuetify/src/components/VSelect/VSelect.tsx index 090e10e1c65..c7eb236179d 100644 --- a/packages/vuetify/src/components/VSelect/VSelect.tsx +++ b/packages/vuetify/src/components/VSelect/VSelect.tsx @@ -618,7 +618,15 @@ export const VSelect = genericComponent { return search.value - ? + ? ( + + ) : item.title }, }} From be21586a928d9a4b512d0b30f3334e850d3007ef Mon Sep 17 00:00:00 2001 From: J-Sek Date: Thu, 14 May 2026 19:15:13 +0200 Subject: [PATCH 8/8] drop unused function --- packages/vuetify/src/composables/filter.tsx | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/vuetify/src/composables/filter.tsx b/packages/vuetify/src/composables/filter.tsx index 9c622ef42f8..675df1b771f 100644 --- a/packages/vuetify/src/composables/filter.tsx +++ b/packages/vuetify/src/composables/filter.tsx @@ -238,19 +238,3 @@ export function useFilter ( return { filteredItems, filteredMatches, getMatches } } - -export function highlightResult (name: string, text: string, matches: FilterMatchArrayMultiple | undefined) { - if (matches == null || !matches.length) return text - - return matches.map((match, i) => { - const start = i === 0 ? 0 : matches[i - 1][1] - const result = [ - { text.slice(start, match[0]) }, - { text.slice(match[0], match[1]) }, - ] - if (i === matches.length - 1) { - result.push({ text.slice(match[1]) }) - } - return <>{ result } - }) -}