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>

Установка

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

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

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