Skip to content

Commit f7d5fd7

Browse files
J-Sekjohnleider
andauthored
feat(toHighlight): add new utility (#222)
Co-authored-by: John Leider <john@vuetifyjs.com>
1 parent 181762a commit f7d5fd7

13 files changed

Lines changed: 836 additions & 9 deletions

File tree

apps/docs/src/examples/composables/create-filter/live-search.vue

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { createFilter } from '@vuetify/v0'
2+
import { createFilter, toHighlight } from '@vuetify/v0'
33
import { computed, shallowRef } from 'vue'
44
55
const cities = [
@@ -21,11 +21,7 @@
2121
const filter = createFilter({ keys: ['name', 'country'] })
2222
const { items } = filter.apply(query, cities)
2323
24-
function highlight (text: string) {
25-
if (!query.value) return text
26-
const regex = new RegExp(`(${query.value})`, 'gi')
27-
return text.replace(regex, '<mark class="bg-warning text-on-warning rounded px-0.5">$1</mark>')
28-
}
24+
const highlightOptions = { ignoreCase: true, matchAll: true }
2925
3026
const hasResults = computed(() => items.value.length > 0)
3127
</script>
@@ -62,9 +58,21 @@
6258
class="px-4 py-3 flex items-center justify-between hover:bg-surface-tint transition-colors"
6359
>
6460
<div>
65-
<span class="font-medium" v-html="highlight(city.name)" />
61+
<span class="font-medium">
62+
<template v-for="(chunk, i) in toHighlight(city.name, query, highlightOptions)" :key="i">
63+
<mark v-if="chunk.match" class="bg-warning text-on-warning rounded px-0.5">{{ chunk.text }}</mark>
64+
<template v-else>{{ chunk.text }}</template>
65+
</template>
66+
</span>
67+
6668
<span class="mx-2 opacity-30">/</span>
67-
<span class="text-sm opacity-70" v-html="highlight(city.country)" />
69+
70+
<span class="text-sm opacity-70">
71+
<template v-for="(chunk, i) in toHighlight(city.country, query, highlightOptions)" :key="i">
72+
<mark v-if="chunk.match" class="bg-warning text-on-warning rounded px-0.5">{{ chunk.text }}</mark>
73+
<template v-else>{{ chunk.text }}</template>
74+
</template>
75+
</span>
6876
</div>
6977

7078
<span class="text-sm font-mono opacity-50">{{ city.population }}</span>
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<script setup lang="ts">
2+
import { toHighlight } from '@vuetify/v0'
3+
import { computed, toRef } from 'vue'
4+
import { snippets } from './messages'
5+
import type { Message } from './messages'
6+
7+
const props = defineProps<{
8+
message: Message
9+
terms: string[]
10+
serverMode: boolean
11+
}>()
12+
13+
const message = toRef(() => props.message)
14+
const options = { ignoreCase: true, matchAll: true } as const
15+
16+
const subject = computed(() => toHighlight(message.value.subject, () => props.terms, options))
17+
18+
const body = computed(() => {
19+
if (props.serverMode) {
20+
return toHighlight(message.value.body, undefined, {
21+
matches: snippets(message.value.body, props.terms),
22+
})
23+
}
24+
return toHighlight(message.value.body, () => props.terms, options)
25+
})
26+
27+
const hits = computed(() =>
28+
subject.value.filter(c => c.match).length + body.value.filter(c => c.match).length,
29+
)
30+
</script>
31+
32+
<template>
33+
<article
34+
class="flex gap-3 p-3 rounded transition-colors hover:bg-surface-tint data-[hit=false]:opacity-50"
35+
:data-hit="hits > 0"
36+
>
37+
<div class="size-9 shrink-0 rounded-full bg-primary/15 text-primary text-sm font-semibold grid place-items-center">
38+
{{ message.from.split(' ').map(p => p[0]).join('').slice(0, 2) }}
39+
</div>
40+
41+
<div class="min-w-0 flex-1">
42+
<header class="flex items-baseline gap-2">
43+
<span class="font-semibold text-on-surface truncate">{{ message.from }}</span>
44+
<span class="ml-auto shrink-0 text-xs text-on-surface/50">{{ message.time }}</span>
45+
</header>
46+
47+
<p class="text-sm font-medium text-on-surface truncate">
48+
<template v-for="(chunk, i) in subject" :key="i">
49+
<mark
50+
v-if="chunk.match"
51+
class="bg-primary/25 text-on-surface rounded px-0.5 not-italic"
52+
>{{ chunk.text }}</mark>
53+
54+
<template v-else>{{ chunk.text }}</template>
55+
</template>
56+
</p>
57+
58+
<p class="text-sm text-on-surface/70 line-clamp-2">
59+
<template v-for="(chunk, i) in body" :key="i">
60+
<mark
61+
v-if="chunk.match"
62+
:class="serverMode
63+
? 'bg-success/25 text-on-surface rounded px-0.5 not-italic decoration-2 underline decoration-success/60'
64+
: 'bg-primary/20 text-on-surface rounded px-0.5 not-italic'"
65+
>{{ chunk.text }}</mark>
66+
67+
<template v-else>{{ chunk.text }}</template>
68+
</template>
69+
</p>
70+
</div>
71+
</article>
72+
</template>
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<script setup lang="ts">
2+
import { Input, Switch, Toggle } from '@vuetify/v0'
3+
import { computed, ref, shallowRef } from 'vue'
4+
import MessageRow from './MessageRow.vue'
5+
import { messages } from './messages'
6+
7+
const query = shallowRef('search')
8+
const serverMode = shallowRef(false)
9+
10+
const filters = ['budget', 'review', 'urgent'] as const
11+
const active = ref<Record<string, boolean>>({})
12+
13+
const terms = computed(() => {
14+
const out: string[] = []
15+
if (query.value.trim()) out.push(query.value.trim())
16+
for (const f of filters) {
17+
if (active.value[f] && !out.some(t => t.toLowerCase() === f)) out.push(f)
18+
}
19+
return out
20+
})
21+
22+
const matching = computed(() => {
23+
if (terms.value.length === 0) return messages
24+
return messages.filter(m => {
25+
const haystack = (m.subject + ' ' + m.body).toLowerCase()
26+
return terms.value.some(t => haystack.includes(t.toLowerCase()))
27+
})
28+
})
29+
</script>
30+
31+
<template>
32+
<div class="flex flex-col gap-3 p-4 max-w-2xl mx-auto">
33+
<Input.Root id="inbox-search" v-model="query" label="Search inbox">
34+
<Input.Control
35+
class="w-full px-3 py-2 rounded-lg border border-divider bg-surface text-on-surface placeholder:text-on-surface/40 outline-none data-[focused]:border-primary transition-colors"
36+
placeholder="Search by sender, subject, or body…"
37+
/>
38+
</Input.Root>
39+
40+
<div class="flex flex-wrap items-center gap-2">
41+
<span class="text-xs font-medium text-on-surface/60">Saved filters:</span>
42+
43+
<Toggle.Root
44+
v-for="f in filters"
45+
:key="f"
46+
v-model="active[f]"
47+
class="px-2.5 py-1 rounded-full border border-divider text-xs font-medium text-on-surface/70 transition-colors data-[state=on]:border-primary data-[state=on]:bg-primary/15 data-[state=on]:text-on-surface hover:bg-surface-tint"
48+
>
49+
{{ f }}
50+
</Toggle.Root>
51+
52+
<label class="ml-auto inline-flex items-center gap-2 text-xs text-on-surface/70 cursor-pointer">
53+
<Switch.Root
54+
v-model="serverMode"
55+
class="inline-flex items-center border-none bg-transparent p-0 outline-none"
56+
>
57+
<Switch.Track class="relative inline-flex items-center w-9 h-5 rounded-full bg-surface-variant transition-colors data-[state=checked]:bg-success">
58+
<Switch.Thumb class="![visibility:visible] block size-3.5 rounded-full bg-white shadow-sm transition-transform translate-x-0.5 data-[state=checked]:translate-x-5" />
59+
</Switch.Track>
60+
</Switch.Root>
61+
Server snippets
62+
</label>
63+
</div>
64+
65+
<div class="flex items-baseline justify-between text-xs text-on-surface/60 px-1">
66+
<span>{{ matching.length }} of {{ messages.length }} threads</span>
67+
68+
<span v-if="terms.length > 0">
69+
Highlighting
70+
<span class="font-mono">{{ JSON.stringify(terms.length === 1 ? terms[0] : terms) }}</span>
71+
</span>
72+
</div>
73+
74+
<div class="flex flex-col rounded-lg border border-divider bg-surface divide-y divide-divider">
75+
<MessageRow
76+
v-for="message in matching"
77+
:key="message.id"
78+
:message
79+
:server-mode
80+
:terms
81+
/>
82+
83+
<p v-if="matching.length === 0" class="p-6 text-center text-sm text-on-surface/60">
84+
No threads match the active filters.
85+
</p>
86+
</div>
87+
</div>
88+
</template>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { MatchRange } from '@vuetify/v0'
2+
3+
export interface Message {
4+
id: number
5+
from: string
6+
subject: string
7+
body: string
8+
time: string
9+
}
10+
11+
export const messages: Message[] = [
12+
{
13+
id: 1,
14+
from: 'Lina Park',
15+
subject: 'Q3 budget review',
16+
body: 'The marketing line item is 18% over forecast. Can we walk through the spend before Friday? I have a draft proposal that trims paid ads without touching the events budget.',
17+
time: '9:42 AM',
18+
},
19+
{
20+
id: 2,
21+
from: 'Marcus Chen',
22+
subject: 'Re: design review for the search experience',
23+
body: 'Loved the new typeahead — the highlight on each result row reads much faster than the underlined variant. One nit: the empty-state copy still says "no matches" instead of "no results".',
24+
time: '8:15 AM',
25+
},
26+
{
27+
id: 3,
28+
from: 'Priya Shah',
29+
subject: 'Weekly product sync notes',
30+
body: 'Recap: launched the new search bar, started the onboarding redesign, paused the budget dashboard work pending review. Open questions on the search ranking tweak are tracked in the planning doc.',
31+
time: 'Yesterday',
32+
},
33+
{
34+
id: 4,
35+
from: 'GitHub',
36+
subject: 'New review request on vuetifyjs/0#222',
37+
body: 'J-Sek requested your review on the toHighlight transformer PR. The branch adds a pure splitter and updates the create-filter live-search example to drop the v-html highlight path.',
38+
time: 'Yesterday',
39+
},
40+
]
41+
42+
function escape (term: string) {
43+
return term.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
44+
}
45+
46+
export function snippets (body: string, terms: string[]): MatchRange[] {
47+
const safe = terms.filter(Boolean)
48+
if (safe.length === 0) return []
49+
const pattern = safe.map(t => String.raw`\b${escape(t)}\w*`).join('|')
50+
const ranges: MatchRange[] = []
51+
for (const m of body.matchAll(new RegExp(pattern, 'gi'))) {
52+
ranges.push([m.index, m.index + m[0].length])
53+
}
54+
return ranges
55+
}

apps/docs/src/pages/composables/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,5 +334,6 @@ Value transformation utilities.
334334
| - | - |
335335
| [toArray](/composables/transformers/to-array) | Convert any value to an array |
336336
| [toElement](/composables/transformers/to-element) | Resolve refs, getters, or component instances to a plain DOM element |
337+
| [toHighlight](/composables/transformers/to-highlight) | Split text into matched and unmatched chunks for query highlighting |
337338
| [toReactive](/composables/transformers/to-reactive) | Convert MaybeRef objects to reactive proxies |
338339

0 commit comments

Comments
 (0)