Skip to content

feat(VHighlight): add new component#22817

Open
J-Sek wants to merge 5 commits into
devfrom
feat/vhighlight
Open

feat(VHighlight): add new component#22817
J-Sek wants to merge 5 commits into
devfrom
feat/vhighlight

Conversation

@J-Sek
Copy link
Copy Markdown
Contributor

@J-Sek J-Sek commented Apr 24, 2026

v-highlight is meant to make it easier to implement custom dropdowns, and filterable list. It might be a good fit for VDataTable as well.

API

  • text - primary piece of content to scan for matches
  • query (string or string array, case-insensitive, overlapping spans merged)
  • matches (array of [start, end]) - takes priority over query
  • mark-class - for customization
  • match-all
  • ignore-case
  • tag - defaults to span
  • color and opacity for customization
    • opacity bumped for "dark" theme (--v-highlight-opacity, CSS variable)

not covered

  • collapse-whitespace - to ignore double spaces, and line breaks (normalize as single space)
    • awaiting feedback based on real-life use cases

Playground

<template>
  <v-app theme="dark">
    <v-container class="py-8" style="max-width: 1200px">

      <v-row class="mt-6">
        <v-col cols="12" sm="4">
          <v-select
            v-model="selectValue"
            :items="countryNames"
            :search="search"
            label="VSelect"
            clearable
          />
        </v-col>

        <v-col cols="12" sm="4">
          <v-combobox
            v-model="comboboxValue"
            :items="countryNames"
            label="VCombobox"
            clearable
          />
        </v-col>

        <v-col cols="12" sm="4">
          <v-autocomplete
            v-model="autocompleteValue"
            :items="countryNames"
            label="VAutocomplete"
            clearable
          />
        </v-col>
      </v-row>

      <v-divider class="mb-6" />

      <v-toolbar class="mb-6 px-6">
        <div class="d-flex ga-6">
          <v-switch v-model="ignoreCase" color="success" label="Ignore case" hide-details />
          <v-switch v-model="matchAll" color="success" label="Match all" hide-details />
        </div>
      </v-toolbar>

      <v-row align="start">
        <v-col cols="12" sm="4">
          <v-text-field
            v-model="search"
            label="Search phrase"
            clearable
            hide-details
          />

          <p class="text-body-2 mt-6" style="line-height: 1.8">
            <v-highlight :ignore-case="ignoreCase" :match-all="matchAll" :query="search ?? undefined" :text="paragraph" />
          </p>
        </v-col>

        <v-col cols="12" sm="8">
          <v-list
            lines="two"
            style="max-height: 420px; overflow-y: auto"
            border
            rounded
          >
            <template v-if="filteredCountries.length">
              <v-list-item v-for="item in filteredCountries" :key="item.title">
                <template #title>
                  <v-highlight :ignore-case="ignoreCase" :match-all="matchAll" :query="search ?? undefined" :text="item.title" />
                </template>
                <template #subtitle>
                  <v-highlight :ignore-case="ignoreCase" :match-all="matchAll" :query="search ?? undefined" :text="item.subtitle" />
                </template>
              </v-list-item>
            </template>

            <v-list-item v-else title="No results" />
          </v-list>
        </v-col>
      </v-row>
    </v-container>
  </v-app>
</template>

<script setup>
  import { computed, shallowRef } from 'vue'

  const search = shallowRef('re')
  const selectValue = shallowRef(null)
  const comboboxValue = shallowRef(null)
  const autocompleteValue = shallowRef(null)
  const ignoreCase = shallowRef(true)
  const matchAll = shallowRef(true)

  const paragraph = 'The European Union spans 27 member states across the continent, ' +
    'from Ireland in the west to Bulgaria and Romania in the east. ' +
    'Members share a single market, a common trade policy, and most use the euro as their currency. ' +
    'The union was established to foster peace, prosperity, and the free movement ' +
    'of people, goods, services, and capital.'

  const countries = [
    { title: 'Austria', subtitle: 'Vienna' },
    { title: 'Belgium', subtitle: 'Brussels' },
    { title: 'Bulgaria', subtitle: 'Sofia' },
    { title: 'Croatia', subtitle: 'Zagreb' },
    { title: 'Cyprus', subtitle: 'Nicosia' },
    { title: 'Czech Republic', subtitle: 'Prague' },
    { title: 'Denmark', subtitle: 'Copenhagen' },
    { title: 'Estonia', subtitle: 'Tallinn' },
    { title: 'Finland', subtitle: 'Helsinki' },
    { title: 'France', subtitle: 'Paris' },
    { title: 'Germany', subtitle: 'Berlin' },
    { title: 'Greece', subtitle: 'Athens' },
    { title: 'Hungary', subtitle: 'Budapest' },
    { title: 'Ireland', subtitle: 'Dublin' },
    { title: 'Italy', subtitle: 'Rome' },
    { title: 'Latvia', subtitle: 'Riga' },
    { title: 'Lithuania', subtitle: 'Vilnius' },
    { title: 'Luxembourg', subtitle: 'Luxembourg City' },
    { title: 'Malta', subtitle: 'Valletta' },
    { title: 'Netherlands', subtitle: 'Amsterdam' },
    { title: 'Poland', subtitle: 'Warsaw' },
    { title: 'Portugal', subtitle: 'Lisbon' },
    { title: 'Romania', subtitle: 'Bucharest' },
    { title: 'Slovakia', subtitle: 'Bratislava' },
    { title: 'Slovenia', subtitle: 'Ljubljana' },
    { title: 'Spain', subtitle: 'Madrid' },
    { title: 'Sweden', subtitle: 'Stockholm' },
  ]

  const countryNames = countries.map(c => c.title)

  const filteredCountries = computed(() => {
    if (!search.value) return countries

    const term = search.value.toLocaleLowerCase()

    return countries.filter(c =>
      c.title.toLocaleLowerCase().includes(term) ||
      c.subtitle.toLocaleLowerCase().includes(term)
    )
  })
</script>

<style>
mark {
  outline: 2px dashed red;
  outline-offset: 2px;
}
</style>

@J-Sek J-Sek added this to the v4.1.0 milestone Apr 24, 2026
@J-Sek J-Sek self-assigned this Apr 24, 2026
@J-Sek J-Sek added the C: New Component This issue would need a new component to be developed. label Apr 24, 2026
@J-Sek J-Sek requested a review from a team April 29, 2026 11:46
@johnleider
Copy link
Copy Markdown
Member

johnleider commented Apr 29, 2026

Nice addition — opening a brainstorm thread on whether any of this should live in v0 first, since the shape is a near-perfect fit for a headless primitive.

What could move to v0

toHighlight as a pure transformer. No state, no registry, no DOM — just (text, query|matches) → Chunk[]. Textbook shape for a v0 to* transformer (sibling to toArray, toElement, toReactive).

// packages/0/src/composables/toHighlight/index.ts
export type HighlightChunk = { text: string, match: boolean }

export interface ToHighlightOptions {
  text: MaybeRefOrGetter<string>
  query?: MaybeRefOrGetter<string | string[] | undefined>
  matches?: MaybeRefOrGetter<MatchRange[] | undefined>
  matchAll?: MaybeRefOrGetter<boolean>
  ignoreCase?: MaybeRefOrGetter<boolean>
}

export function toHighlight (options: ToHighlightOptions): ComputedRef<HighlightChunk[]>

Usage:

import { toHighlight } from '@vuetify/v0'

const chunks = toHighlight({
  text: () => props.text,
  query: () => props.query,
  ignoreCase: true,
})

createFilter matches extension. v0's createFilter returns filtered items but no positional data. Optional opt-in:

// packages/0/src/composables/createFilter/index.ts
const filter = createFilter({ mode: 'some', matches: true })
const { items, matches } = filter.apply(query, items)

// matches: ComputedRef<Map<FilterItem, MatchRange[]>>

Then end-to-end, with no private types crossing boundaries:

const filter = createFilter({ mode: 'some', matches: true })
const { items, matches } = filter.apply(search, allItems)

// In each row:
const chunks = toHighlight({
  text: () => row.title,
  matches: () => matches.value.get(row),
})

What VHighlight could pull from v0

VHighlight.tsx becomes a thin renderer — no internal highlight.ts, no __tests__/highlight.spec.ts (covered upstream):

// packages/vuetify/src/labs/VHighlight/VHighlight.tsx
import { toHighlight } from '@vuetify/v0'

export const VHighlight = defineComponent({
  name: 'VHighlight',
  props: makeVHighlightProps(),
  setup (props) {
    const chunks = toHighlight({
      text: () => props.text,
      query: () => props.query,
      matches: () => props.matches,
      matchAll: () => props.matchAll,
      ignoreCase: () => props.ignoreCase,
    })

    return () => (
      <props.tag class="v-highlight">
        { chunks.value.map((chunk, i) => chunk.match
          ? <mark key={i} class={['v-highlight__mark', props.markClass]}>{chunk.text}</mark>
          : <span key={i}>{chunk.text}</span>
        )}
      </props.tag>
    )
  },
})

Benefits:

  • Drops the FilterMatchArrayMultiple type leak from a private filter type into a public labs prop.
  • Tests live with the logic, not the renderer.

What probably shouldn't move to v0

The <VHighlight> component itself. v0 stays headless — a <mark>-wrapping component is borderline styling. Composable carries 100% of the logic; this component is a 20-line render. Consumers who want the headless path use toHighlight + their own template:

<template>
  <p>
    <template v-for="(chunk, i) in chunks" :key="i">
      <mark v-if="chunk.match" :class="myCustomClass">{{ chunk.text }}</mark>
      <template v-else>{{ chunk.text }}</template>
    </template>
  </p>
</template>

Independent of v0 — nits on this PR

  • matchAll on the matches branch: matches.slice(0, 1) silently truncates user-supplied ranges when matchAll: false. The flag is meaningful for query-derived spans; on pre-computed input it's surprising. Suggest ignoring matchAll when matches is provided.
  • while (~i) reads cleaner as while (i !== -1).
  • Consider exporting useHighlight and HighlightChunk so consumers can compose without rendering — escape hatch matches what v0 would expose.

Happy to open v0 issues for toHighlight + the createFilter matches extension if there's appetite. Could land in v0 before this leaves labs, so the eventual swap is one import line.

@J-Sek
Copy link
Copy Markdown
Contributor Author

J-Sek commented Apr 29, 2026

It was designed to be moved to v0 as a component, but since the presentation layer is so thin, we can go with toHighlight as well (v0#222).

@J-Sek J-Sek force-pushed the feat/vhighlight branch from 0c01012 to 4f9802c Compare May 14, 2026 13:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

C: New Component This issue would need a new component to be developed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants