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>

Установка

npx shadcn-vue@latest add https://revueui.rootapi.dev/r/c-combobox-24.json

Зависимости реестра

Источник: порт из ReUI (Keenthemes, MIT)