combobox
An assignee selection combobox
An assignee selection combobox
Загрузка превью…
src/combobox/c-combobox-28.vue
<!--
Unsplash-URL заменены локальным 1x1 data-URI (см. `c-combobox-10.vue`).
`IconPlaceholder` заменён инлайновым `<svg>` (lucide-react v0.545.0:
"user", "send"). Фабрики `VNodeChild` через `<component :is="() => fn()" />`,
как в `c-combobox-21.vue`.
-->
<script setup lang="ts">
import { h, ref, type VNodeChild } from "vue"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { buttonVariants } from "@/components/ui/button"
import { Combobox, ComboboxCollection, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxLabel, ComboboxList, ComboboxSeparator, ComboboxTrigger, ComboboxValue } from "@/components/ui/combobox"
import { Field } from "@/components/ui/field"
import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item"
import { cn } from "@/lib/utils"
const PIXEL =
"data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAUwAOw=="
type NoAssigneeOption = { type: "none"; id: "no-assignee"; value: "none"; label: string; searchText: string }
type TeamMember = {
type: "member"
id: string
value: string
label: string
avatar: string
initials: string
isCurrentUser?: boolean
searchText: string
}
type InviteOption = { type: "invite"; id: "invite-user"; value: "invite"; label: string; searchText: string }
type AssigneeSelectionItem = NoAssigneeOption | TeamMember | InviteOption
const noAssigneeOption: NoAssigneeOption = {
type: "none",
id: "no-assignee",
value: "none",
label: "No assignee",
searchText: "No assignee unassigned clear assignee remove assignee",
}
const teamMembers: TeamMember[] = [
{ type: "member", id: "member-1", value: "alex-morgan", label: "Alex Morgan", avatar: PIXEL, initials: "AM", isCurrentUser: true, searchText: "Alex Morgan current user owner" },
{ type: "member", id: "member-2", value: "emma-carter", label: "Emma Carter", avatar: PIXEL, initials: "EC", searchText: "Emma Carter product design" },
{ type: "member", id: "member-3", value: "ryan-mitchell", label: "Ryan Mitchell", avatar: PIXEL, initials: "RM", searchText: "Ryan Mitchell engineering backend" },
{ type: "member", id: "member-4", value: "olivia-bennett", label: "Olivia Bennett", avatar: PIXEL, initials: "OB", searchText: "Olivia Bennett growth marketing" },
{ type: "member", id: "member-5", value: "ethan-brooks", label: "Ethan Brooks", avatar: PIXEL, initials: "EB", searchText: "Ethan Brooks operations" },
]
const inviteOption: InviteOption = {
type: "invite",
id: "invite-user",
value: "invite",
label: "Invite...",
searchText: "Invite new user teammate member collaborator",
}
function userGlyph(): 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: "M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" }),
h("circle", { cx: "12", cy: "7", r: "4" }),
]
)
}
function paperPlaneIcon(): 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: "M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z",
}),
h("path", { d: "m21.854 2.147-10.94 10.939" }),
]
)
}
function assigneeTriggerLabel(option: NoAssigneeOption | TeamMember | null, placeholder: string): VNodeChild {
if (!option) {
return h("span", { class: "text-muted-foreground truncate" }, placeholder)
}
if (option.type === "none") {
return h("span", { class: "flex min-w-0 items-center gap-2" }, [
userGlyph(),
h("span", { class: "truncate" }, option.label),
])
}
return h("span", { class: "flex min-w-0 items-center gap-2" }, [
h(Avatar, { class: "size-5" }, () => [
h(AvatarImage, { src: option.avatar, alt: option.label }),
h(AvatarFallback, { class: "text-[9px]" }, () => option.initials),
]),
h("span", { class: "truncate" }, option.label),
])
}
function assigneeListRow(option: NoAssigneeOption | TeamMember | InviteOption): VNodeChild {
if (option.type === "none") {
return h("span", { class: "flex min-w-0 items-center gap-2" }, [
userGlyph(),
h("span", { class: "truncate" }, option.label),
])
}
if (option.type === "invite") {
return h("span", { class: "flex min-w-0 items-center gap-2" }, [
paperPlaneIcon(),
h("span", { class: "truncate" }, option.label),
])
}
return h(Item, { size: "xs", class: "p-0" }, () => [
h(ItemMedia, {}, () => [
h(Avatar, { class: "size-5" }, () => [
h(AvatarImage, { src: option.avatar, alt: option.label }),
h(AvatarFallback, { class: "text-[9px]" }, () => option.initials),
]),
]),
h(ItemContent, {}, () => [
h(ItemTitle, { class: "gap-1 whitespace-nowrap" }, () => [
h("span", {}, option.label),
option.isCurrentUser ? h("span", { class: "text-muted-foreground font-normal" }, "(You)") : null,
]),
]),
])
}
const assignee = ref<TeamMember | null>(null)
const selectedAssignee = ref<NoAssigneeOption | TeamMember>(assignee.value ?? noAssigneeOption)
function handleAssigneeChange(nextAssignee: AssigneeSelectionItem | null) {
if (!nextAssignee || nextAssignee.type === "invite") return
assignee.value = nextAssignee.type === "member" ? nextAssignee : null
selectedAssignee.value = assignee.value ?? noAssigneeOption
}
</script>
<template>
<Field class="max-w-xs">
<Combobox
:model-value="selectedAssignee"
@update:model-value="(v) => handleAssigneeChange(v as AssigneeSelectionItem | null)"
>
<ComboboxTrigger
:class="cn(buttonVariants({ variant: 'outline' }), 'w-full justify-between font-normal')"
>
<ComboboxValue v-slot="{ value }">
<component :is="() => assigneeTriggerLabel(value as NoAssigneeOption | TeamMember | null, 'No assignee')" />
</ComboboxValue>
</ComboboxTrigger>
<ComboboxContent class="max-w-(--anchor-width) min-w-(--anchor-width)">
<ComboboxInput :show-trigger="false" placeholder="Select assignee" class="mb-1" />
<ComboboxEmpty>No assignees found.</ComboboxEmpty>
<ComboboxList>
<ComboboxItem :value="noAssigneeOption">
<component :is="() => assigneeListRow(noAssigneeOption)" />
</ComboboxItem>
<ComboboxSeparator />
<ComboboxGroup>
<ComboboxLabel>Team members</ComboboxLabel>
<ComboboxCollection>
<ComboboxItem v-for="member in teamMembers" :key="member.id" :value="member">
<component :is="() => assigneeListRow(member)" />
</ComboboxItem>
</ComboboxCollection>
</ComboboxGroup>
<ComboboxSeparator />
<ComboboxGroup>
<ComboboxLabel>New user</ComboboxLabel>
<ComboboxCollection>
<ComboboxItem :value="inviteOption">
<component :is="() => assigneeListRow(inviteOption)" />
</ComboboxItem>
</ComboboxCollection>
</ComboboxGroup>
</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>