combobox
A multi-label selection combobox with a no-label mode
A multi-label selection combobox with a no-label mode
Загрузка превью…
src/combobox/c-combobox-26.vue
<!--
`IconPlaceholder` заменён инлайновым `<svg>` (lucide-react v0.545.0: "tag").
`TagGlyph`/`LabelTriggerSummary`/`LabelOptionRow` — фабрики `VNodeChild`
через `<component :is="() => fn()" />`, как в `c-combobox-21.vue`.
-->
<script setup lang="ts">
import { h, ref, type VNodeChild } from "vue"
import { buttonVariants } from "@/components/ui/button"
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, ComboboxSeparator, ComboboxTrigger, ComboboxValue } from "@/components/ui/combobox"
import { Field } from "@/components/ui/field"
import { cn } from "@/lib/utils"
type EmptyLabelOption = { type: "none"; id: "no-label"; label: string; searchText: string }
type LabelOption = {
type: "label"
id: string
value: string
label: string
colorClassName: string
searchText: string
}
type LabelSelectionItem = EmptyLabelOption | LabelOption
const noLabelOption: EmptyLabelOption = {
type: "none",
id: "no-label",
label: "No label",
searchText: "No label clear labels remove labels empty",
}
const labelOptions: LabelOption[] = [
{ type: "label", id: "feature", value: "feature", label: "Feature", colorClassName: "bg-sky-500", searchText: "Feature product enhancement capability" },
{ type: "label", id: "bug", value: "bug", label: "Bug", colorClassName: "bg-rose-500", searchText: "Bug issue defect problem" },
{ type: "label", id: "improvement", value: "improvement", label: "Improvement", colorClassName: "bg-emerald-500", searchText: "Improvement refinement polish optimization" },
{ type: "label", id: "design", value: "design", label: "Design", colorClassName: "bg-violet-500", searchText: "Design ui ux creative visual" },
]
function tagGlyph(): VNodeChild {
return h(
"svg",
{
xmlns: "http://www.w3.org/2000/svg",
width: "24",
height: "24",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
class: "size-4 shrink-0 text-muted-foreground",
},
[
h("path", {
d: "M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z",
}),
h("circle", { cx: "7.5", cy: "7.5", r: ".5", fill: "currentColor" }),
]
)
}
function labelDot(option: LabelOption): VNodeChild {
return h("span", { "aria-hidden": "true", class: `size-3 shrink-0 rounded-full ${option.colorClassName}` })
}
function labelTriggerSummary(selectedLabels: LabelOption[]): VNodeChild {
if (!selectedLabels.length) {
return h("span", { class: "flex min-w-0 items-center gap-2" }, [
tagGlyph(),
h("span", { class: "text-muted-foreground truncate" }, "Add label"),
])
}
const visibleLabels = selectedLabels.slice(0, 3)
const hiddenLabelCount = selectedLabels.length - visibleLabels.length
const selectedLabelText = selectedLabels.map((label) => label.label).join(", ")
if (selectedLabels.length === 1) {
const [label] = selectedLabels
return h("span", { class: "flex min-w-0 items-center gap-2" }, [
labelDot(label!),
h("span", { class: "truncate" }, label!.label),
])
}
return h("span", { class: "flex min-w-0 items-center gap-1" }, [
h("span", { class: "sr-only" }, `Selected labels: ${selectedLabelText}`),
...visibleLabels.map((label) => labelDot(label)),
hiddenLabelCount > 0
? h("span", { class: "text-muted-foreground ml-0.5 text-xs tabular-nums" }, `+${hiddenLabelCount}`)
: null,
])
}
function labelOptionRow(option: LabelSelectionItem): VNodeChild {
if (option.type === "none") {
return h("span", { class: "truncate" }, option.label)
}
return h("span", { class: "flex min-w-0 items-center gap-2" }, [
labelDot(option),
h("span", { class: "truncate" }, option.label),
])
}
const labels = ref<LabelOption[]>(labelOptions)
function handleLabelsChange(nextItems: LabelSelectionItem[]) {
if (nextItems.some((item) => item.type === "none")) {
labels.value = []
return
}
labels.value = nextItems.filter((item): item is LabelOption => item.type === "label")
}
</script>
<template>
<Field class="max-w-xs">
<Combobox
multiple
:by="(a, b) => (a as LabelSelectionItem).id === (b as LabelSelectionItem).id"
:model-value="labels"
@update:model-value="(v) => handleLabelsChange(v as unknown as LabelSelectionItem[])"
>
<ComboboxTrigger
:class="cn(buttonVariants({ variant: 'outline' }), 'w-full justify-between font-normal')"
>
<ComboboxValue v-slot="{ value }">
<component :is="() => labelTriggerSummary((value as LabelOption[]) ?? [])" />
</ComboboxValue>
</ComboboxTrigger>
<ComboboxContent class="max-w-(--anchor-width) min-w-(--anchor-width)">
<ComboboxInput :show-trigger="false" placeholder="Select labels" class="mb-1" />
<ComboboxEmpty>No labels found.</ComboboxEmpty>
<ComboboxList>
<ComboboxItem :value="noLabelOption">
<component :is="() => labelOptionRow(noLabelOption)" />
</ComboboxItem>
<ComboboxSeparator />
<ComboboxItem v-for="item in labelOptions" :key="item.id" :value="item">
<component :is="() => labelOptionRow(item)" />
</ComboboxItem>
</ComboboxList>
</ComboboxContent>
</Combobox>
</Field>
</template>
<style scoped>
/* reka-ui's ComboboxRoot renders a real DOM wrapper div (via internal
ListboxRoot Primitive) around its slot content, unlike Base UI's
headless ComboboxPrimitive.Root, which renders nothing. When the only
child is a ComboboxTrigger-as-button (no sibling ComboboxInput), this
unstyled div participates in an inline formatting context and can add
a stray 1-3px of height depending on the theme's font metrics --
invisible but breaks pixel parity with the React reference, which has
no such wrapper. display:contents removes the anonymous box without
touching layout/functionality (the div stays in the DOM, still used
internally by reka-ui as the popper anchor ref). */
:deep(div[dir]) {
display: contents;
}
</style>