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..46ea64b0d8f --- /dev/null +++ b/packages/api-generator/src/locale/en/VHighlight.json @@ -0,0 +1,11 @@ +{ + "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. 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/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-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 new file mode 100644 index 00000000000..3236e878203 --- /dev/null +++ b/packages/docs/src/examples/v-highlight/misc-selection-match.vue @@ -0,0 +1,63 @@ + + + + + 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..620761c25a5 --- /dev/null +++ b/packages/docs/src/examples/v-highlight/usage.vue @@ -0,0 +1,55 @@ + + + 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..c95a0e019ac --- /dev/null +++ b/packages/docs/src/pages/en/components/highlights.md @@ -0,0 +1,86 @@ +--- +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. 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 + +#### 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/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..7fcc3d2282b 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,15 @@ 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..9cae645b34c 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,15 @@ 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..c7eb236179d 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,15 @@ export const VSelect = genericComponent { return search.value - ? highlightResult('v-select', item.title, getMatches(item)?.title) + ? ( + + ) : item.title }, }} 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/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 } - }) -} 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 new file mode 100644 index 00000000000..8f4ecda824a --- /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: 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 new file mode 100644 index 00000000000..a595207168b --- /dev/null +++ b/packages/vuetify/src/labs/VHighlight/VHighlight.tsx @@ -0,0 +1,73 @@ +// Styles +import './VHighlight.sass' + +// Composables +import { useTextColor } from '@/composables/color' +import { makeTagProps } from '@/composables/tag' + +// Utilities +import { computed } from 'vue' +import { toHighlight } from './toHighlight' +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, + matchAll: Boolean, + ignoreCase: Boolean, + color: String, + opacity: [String, Number], + markClass: String, + ...makeTagProps({ tag: 'span' }), +}, 'VHighlight') + +export const VHighlight = defineComponent({ + name: 'VHighlight', + + props: makeVHighlightProps(), + + setup (props) { + const chunks = computed(() => toHighlight( + () => props.text, + () => props.query, + { + matches: () => props.matches, + matchAll: () => props.matchAll, + ignoreCase: () => props.ignoreCase, + }, + )) + + const { textColorClasses, textColorStyles } = useTextColor(() => props.color) + + 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__/toHighlight.spec.ts b/packages/vuetify/src/labs/VHighlight/__tests__/toHighlight.spec.ts new file mode 100644 index 00000000000..51c81dd0b42 --- /dev/null +++ b/packages/vuetify/src/labs/VHighlight/__tests__/toHighlight.spec.ts @@ -0,0 +1,111 @@ +import { toHighlight } from '../toHighlight' + +describe('toHighlight', () => { + describe('with pre-computed matches', () => { + it('does 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('does 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('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(toHighlight('Hello World', 'HELLO')).toStrictEqual([ + { text: 'Hello World', match: false }, + ]) + }) + + it('matches case-insensitively when ignoreCase is true', () => { + const chunks = toHighlight('Hello World', 'HELLO', { ignoreCase: true }) + expect(chunks[0]).toStrictEqual({ text: 'Hello', match: true }) + }) + + 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', () => { + 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)', () => { + expect(toHighlight('foobar', ['foo', 'bar'])).toStrictEqual([ + { text: 'foobar', match: true }, + ]) + }) + + it('ignores empty strings in a query array', () => { + expect(toHighlight('hello', ['', 'ell'])).toStrictEqual([ + { text: 'h', match: false }, + { text: 'ell', match: true }, + { text: 'o', match: false }, + ]) + }) + }) + + describe('matchAll is ignored when matches are pre-computed', () => { + it('renders all provided spans regardless of matchAll: false', () => { + 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 }, + ]) + }) + }) + + describe('priority', () => { + it('uses 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('falls 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 }, + ]) + }) + }) +}) diff --git a/packages/vuetify/src/labs/VHighlight/_variables.scss b/packages/vuetify/src/labs/VHighlight/_variables.scss new file mode 100644 index 00000000000..2fa685feea0 --- /dev/null +++ b/packages/vuetify/src/labs/VHighlight/_variables.scss @@ -0,0 +1,5 @@ +// VHighlight +$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; 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/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 }] +} 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'