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>

Установка

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

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

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