combobox
A multi-member selection combobox
A multi-member selection combobox
Загрузка превью…
src/combobox/c-combobox-24.vue
<!--
Unsplash-URL заменены локальным 1x1 data-URI (см. `c-combobox-10.vue`).
`UsersGlyph`/`MembersTriggerSummary`/`MemberListRow`/`MemberSelectionRow` —
фабрики `VNodeChild` через `<component :is="() => fn()" />`, как в
`c-combobox-21.vue`.
-->
<script setup lang="ts">
import { h, ref, type VNodeChild } from "vue"
import { Avatar, AvatarFallback, AvatarGroup, AvatarImage } from "@/components/ui/avatar"
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 { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item"
import { cn } from "@/lib/utils"
const PIXEL =
"data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAUwAOw=="
type TeamMember = {
id: string
value: string
label: string
avatar?: string
initials: string
isCurrentUser?: boolean
}
type EmptyMembersOption = { id: "no-members"; value: "none"; label: string; searchText: string }
type MemberSelectionItem = EmptyMembersOption | TeamMember
const noMembersOption: EmptyMembersOption = {
id: "no-members",
value: "none",
label: "No members",
searchText: "No members clear members remove members empty",
}
const teamMembers: TeamMember[] = [
{ id: "member-1", value: "alex-morgan", label: "Alex Morgan", avatar: PIXEL, initials: "AM", isCurrentUser: true },
{ id: "member-2", value: "emma-carter", label: "Emma Carter", avatar: PIXEL, initials: "EC" },
{ id: "member-3", value: "ryan-mitchell", label: "Ryan Mitchell", initials: "RM" },
{ id: "member-4", value: "olivia-bennett", label: "Olivia Bennett", avatar: PIXEL, initials: "OB" },
{ id: "member-5", value: "ethan-brooks", label: "Ethan Brooks", avatar: PIXEL, initials: "EB" },
{ id: "member-6", value: "sophia-reed", label: "Sophia Reed", avatar: PIXEL, initials: "SR" },
{ id: "member-7", value: "lucas-hayes", label: "Lucas Hayes", avatar: PIXEL, initials: "LH" },
{ id: "member-8", value: "ava-sinclair", label: "Ava Sinclair", initials: "AS" },
{ id: "member-9", value: "mia-parker", label: "Mia Parker", avatar: PIXEL, initials: "MP" },
{ id: "member-10", value: "noah-foster", label: "Noah Foster", avatar: PIXEL, initials: "NF" },
{ id: "member-11", value: "grace-collins", label: "Grace Collins", initials: "GC" },
{ id: "member-12", value: "jack-turner", label: "Jack Turner", avatar: PIXEL, initials: "JT" },
]
function isNoMembersOption(item: MemberSelectionItem): item is EmptyMembersOption {
return item.id === noMembersOption.id
}
function usersGlyph(): 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" }),
]
)
}
function memberAvatar(member: TeamMember, className: string, fallbackClassName?: string): VNodeChild {
return h(Avatar, { class: className }, () => [
member.avatar ? h(AvatarImage, { src: member.avatar, alt: member.label }) : null,
h(AvatarFallback, { class: cn("text-foreground text-[9px]", fallbackClassName) }, () => member.initials),
])
}
function membersTriggerSummary(selectedMembers: TeamMember[]): VNodeChild {
if (!selectedMembers.length) {
return h("span", { class: "flex min-w-0 items-center gap-2" }, [
usersGlyph(),
h("span", { class: "truncate" }, "Members"),
])
}
const visibleMembers = selectedMembers.slice(0, 4)
const hiddenMemberCount = selectedMembers.length - visibleMembers.length
const selectedLabel = selectedMembers.map((member) => member.label).join(", ")
const countLabel =
selectedMembers.length === 1
? "1 member"
: hiddenMemberCount > 0
? `+${hiddenMemberCount} ${hiddenMemberCount === 1 ? "member" : "members"}`
: `${selectedMembers.length} members`
return h("span", { class: "flex min-w-0 items-center gap-2" }, [
h("span", { class: "sr-only" }, `Selected members: ${selectedLabel}`),
h(
AvatarGroup,
{ class: "-space-x-1" },
() => visibleMembers.map((member) => memberAvatar(member, "size-5", "text-[9px]"))
),
h("span", { class: "truncate" }, countLabel),
])
}
function memberListRow(member: TeamMember): VNodeChild {
return h(Item, { size: "xs", class: "p-0" }, () => [
h(ItemMedia, {}, () => [memberAvatar(member, "size-5")]),
h(ItemContent, {}, () => [
h(ItemTitle, { class: "gap-1 whitespace-nowrap" }, () => [
h("span", {}, member.label),
member.isCurrentUser ? h("span", { class: "text-muted-foreground font-normal" }, "(You)") : null,
]),
]),
])
}
function memberSelectionRow(option: MemberSelectionItem): VNodeChild {
if (isNoMembersOption(option)) {
return h("span", { class: "flex min-w-0 items-center gap-2" }, [
usersGlyph(),
h("span", { class: "truncate" }, option.label),
])
}
return memberListRow(option)
}
const members = ref<TeamMember[]>(teamMembers.slice(0, 5))
function handleMembersChange(nextMembers: MemberSelectionItem[]) {
if (nextMembers.some(isNoMembersOption)) {
members.value = []
return
}
members.value = nextMembers.filter((m): m is TeamMember => !isNoMembersOption(m))
}
</script>
<template>
<Field class="max-w-xs">
<Combobox
multiple
:by="(a, b) => (a as MemberSelectionItem).id === (b as MemberSelectionItem).id"
:model-value="members"
@update:model-value="(v) => handleMembersChange(v as unknown as MemberSelectionItem[])"
>
<ComboboxTrigger
:class="cn(buttonVariants({ variant: 'outline' }), 'w-full justify-between font-normal')"
>
<ComboboxValue v-slot="{ value }">
<component :is="() => membersTriggerSummary((value as TeamMember[]) ?? [])" />
</ComboboxValue>
</ComboboxTrigger>
<ComboboxContent class="max-w-(--anchor-width) min-w-(--anchor-width)">
<ComboboxInput :show-trigger="false" placeholder="Select members" class="mb-1" />
<ComboboxEmpty>No members found.</ComboboxEmpty>
<ComboboxList>
<ComboboxItem :value="noMembersOption">
<component :is="() => memberSelectionRow(noMembersOption)" />
</ComboboxItem>
<ComboboxSeparator />
<ComboboxItem v-for="member in teamMembers" :key="member.id" :value="member">
<component :is="() => memberSelectionRow(member)" />
</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>