combobox
A team selection combobox
A team selection combobox
Загрузка превью…
src/combobox/c-combobox-27.vue
<!--
`IconPlaceholder` заменён инлайновым `<svg>` (lucide-react v0.545.0:
"users", "layers", "briefcase-business", "git-branch", "package",
"sparkles"). Фабрики `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 EmptyTeamOption = { type: "none"; id: "no-team"; label: string; searchText: string }
type TeamOption = {
type: "team"
id: string
value: string
label: string
tone: "lime" | "sky" | "violet" | "amber" | "rose"
searchText: string
}
type TeamSelectionItem = EmptyTeamOption | TeamOption
const noTeamOption: EmptyTeamOption = {
type: "none",
id: "no-team",
label: "No team",
searchText: "No team clear team remove team empty",
}
const teamOptions: TeamOption[] = [
{ type: "team", id: "testo-growth", value: "testo-growth", label: "Testo Growth", tone: "lime", searchText: "Testo Growth experiments lifecycle activation" },
{ type: "team", id: "atlas-platform", value: "atlas-platform", label: "Atlas Platform", tone: "sky", searchText: "Atlas Platform infrastructure systems" },
{ type: "team", id: "nova-studio", value: "nova-studio", label: "Nova Studio", tone: "violet", searchText: "Nova Studio design systems creative" },
{ type: "team", id: "pulse-support", value: "pulse-support", label: "Pulse Support", tone: "rose", searchText: "Pulse Support product health customer care" },
{ type: "team", id: "orbit-ops", value: "orbit-ops", label: "Orbit Ops", tone: "amber", searchText: "Orbit Ops operations dependencies releases" },
]
function emptyTeamGlyph(): 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: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" }),
h("path", { d: "M16 3.128a4 4 0 0 1 0 7.744" }),
h("path", { d: "M22 21v-2a4 4 0 0 0-3-3.87" }),
h("circle", { cx: "9", cy: "7", r: "4" }),
]
)
}
const teamToneClassName: Record<TeamOption["tone"], string> = {
lime: "text-lime-600! [&_*]:text-lime-600!",
sky: "text-sky-600! [&_*]:text-sky-600!",
violet: "text-violet-600! [&_*]:text-violet-600!",
amber: "text-amber-600! [&_*]:text-amber-600!",
rose: "text-rose-600! [&_*]:text-rose-600!",
}
const svgAttrs = {
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",
}
function teamMark(team: TeamOption): VNodeChild {
const className = cn("size-4 shrink-0", teamToneClassName[team.tone])
switch (team.tone) {
case "sky":
return h("svg", { ...svgAttrs, class: className }, [
h("path", {
d: "M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z",
}),
h("path", { d: "M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" }),
h("path", { d: "M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" }),
])
case "violet":
return h("svg", { ...svgAttrs, class: className }, [
h("path", { d: "M12 12h.01" }),
h("path", { d: "M16 6V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" }),
h("path", { d: "M22 13a18.15 18.15 0 0 1-20 0" }),
h("rect", { width: "20", height: "14", x: "2", y: "6", rx: "2" }),
])
case "amber":
return h("svg", { ...svgAttrs, class: className }, [
h("line", { x1: "6", x2: "6", y1: "3", y2: "15" }),
h("circle", { cx: "18", cy: "6", r: "3" }),
h("circle", { cx: "6", cy: "18", r: "3" }),
h("path", { d: "M18 9a9 9 0 0 1-9 9" }),
])
case "rose":
return h("svg", { ...svgAttrs, class: className }, [
h("path", {
d: "M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z",
}),
h("path", { d: "M12 22V12" }),
h("polyline", { points: "3.29 7 12 12 20.71 7" }),
h("path", { d: "m7.5 4.27 9 5.15" }),
])
case "lime":
default:
return h("svg", { ...svgAttrs, class: className }, [
h("path", {
d: "M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z",
}),
h("path", { d: "M20 2v4" }),
h("path", { d: "M22 4h-4" }),
h("circle", { cx: "4", cy: "20", r: "2" }),
])
}
}
function teamTriggerLabel(value: TeamSelectionItem | null, placeholder: string): VNodeChild {
if (!value) {
return h("span", { class: "text-muted-foreground truncate" }, placeholder)
}
if (value.type === "none") {
return h("span", { class: "flex min-w-0 items-center gap-2" }, [
emptyTeamGlyph(),
h("span", { class: "truncate" }, value.label),
])
}
return h("span", { class: "flex min-w-0 items-center gap-2" }, [
teamMark(value),
h("span", { class: "truncate" }, value.label),
])
}
function teamOptionRow(option: TeamSelectionItem): VNodeChild {
if (option.type === "none") {
return h("span", { class: "flex min-w-0 items-center gap-2" }, [
emptyTeamGlyph(),
h("span", { class: "truncate" }, option.label),
])
}
return h("span", { class: "flex min-w-0 items-center gap-2" }, [
teamMark(option),
h("span", { class: "truncate" }, option.label),
])
}
const team = ref<TeamOption | null>(teamOptions[0]!)
const selectedTeam = ref<TeamSelectionItem>(team.value ?? noTeamOption)
function handleTeamChange(nextTeam: TeamSelectionItem | null) {
team.value = nextTeam?.type === "team" ? nextTeam : null
selectedTeam.value = team.value ?? noTeamOption
}
</script>
<template>
<Field class="max-w-xs">
<Combobox
:model-value="selectedTeam"
@update:model-value="(v) => handleTeamChange(v as TeamSelectionItem | null)"
>
<ComboboxTrigger
:class="cn(buttonVariants({ variant: 'outline' }), 'w-full justify-between font-normal')"
>
<ComboboxValue v-slot="{ value }">
<component :is="() => teamTriggerLabel(value as TeamSelectionItem | null, 'Select team')" />
</ComboboxValue>
</ComboboxTrigger>
<ComboboxContent class="max-w-(--anchor-width) min-w-(--anchor-width)">
<ComboboxInput :show-trigger="false" placeholder="Select team" class="mb-1" />
<ComboboxEmpty>No teams found.</ComboboxEmpty>
<ComboboxList>
<ComboboxItem :value="noTeamOption">
<component :is="() => teamOptionRow(noTeamOption)" />
</ComboboxItem>
<ComboboxSeparator />
<ComboboxItem v-for="item in teamOptions" :key="item.id" :value="item">
<component :is="() => teamOptionRow(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>