combobox

A multi-label selection combobox with a no-label mode

A multi-label selection combobox with a no-label mode

Загрузка превью…

src/combobox/c-combobox-26.vue

<!--
  `IconPlaceholder` заменён инлайновым `<svg>` (lucide-react v0.545.0: "tag").
  `TagGlyph`/`LabelTriggerSummary`/`LabelOptionRow` — фабрики `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 EmptyLabelOption = { type: "none"; id: "no-label"; label: string; searchText: string }
type LabelOption = {
  type: "label"
  id: string
  value: string
  label: string
  colorClassName: string
  searchText: string
}
type LabelSelectionItem = EmptyLabelOption | LabelOption

const noLabelOption: EmptyLabelOption = {
  type: "none",
  id: "no-label",
  label: "No label",
  searchText: "No label clear labels remove labels empty",
}

const labelOptions: LabelOption[] = [
  { type: "label", id: "feature", value: "feature", label: "Feature", colorClassName: "bg-sky-500", searchText: "Feature product enhancement capability" },
  { type: "label", id: "bug", value: "bug", label: "Bug", colorClassName: "bg-rose-500", searchText: "Bug issue defect problem" },
  { type: "label", id: "improvement", value: "improvement", label: "Improvement", colorClassName: "bg-emerald-500", searchText: "Improvement refinement polish optimization" },
  { type: "label", id: "design", value: "design", label: "Design", colorClassName: "bg-violet-500", searchText: "Design ui ux creative visual" },
]

function tagGlyph(): 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: "M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z",
      }),
      h("circle", { cx: "7.5", cy: "7.5", r: ".5", fill: "currentColor" }),
    ]
  )
}

function labelDot(option: LabelOption): VNodeChild {
  return h("span", { "aria-hidden": "true", class: `size-3 shrink-0 rounded-full ${option.colorClassName}` })
}

function labelTriggerSummary(selectedLabels: LabelOption[]): VNodeChild {
  if (!selectedLabels.length) {
    return h("span", { class: "flex min-w-0 items-center gap-2" }, [
      tagGlyph(),
      h("span", { class: "text-muted-foreground truncate" }, "Add label"),
    ])
  }

  const visibleLabels = selectedLabels.slice(0, 3)
  const hiddenLabelCount = selectedLabels.length - visibleLabels.length
  const selectedLabelText = selectedLabels.map((label) => label.label).join(", ")

  if (selectedLabels.length === 1) {
    const [label] = selectedLabels
    return h("span", { class: "flex min-w-0 items-center gap-2" }, [
      labelDot(label!),
      h("span", { class: "truncate" }, label!.label),
    ])
  }

  return h("span", { class: "flex min-w-0 items-center gap-1" }, [
    h("span", { class: "sr-only" }, `Selected labels: ${selectedLabelText}`),
    ...visibleLabels.map((label) => labelDot(label)),
    hiddenLabelCount > 0
      ? h("span", { class: "text-muted-foreground ml-0.5 text-xs tabular-nums" }, `+${hiddenLabelCount}`)
      : null,
  ])
}

function labelOptionRow(option: LabelSelectionItem): VNodeChild {
  if (option.type === "none") {
    return h("span", { class: "truncate" }, option.label)
  }
  return h("span", { class: "flex min-w-0 items-center gap-2" }, [
    labelDot(option),
    h("span", { class: "truncate" }, option.label),
  ])
}

const labels = ref<LabelOption[]>(labelOptions)

function handleLabelsChange(nextItems: LabelSelectionItem[]) {
  if (nextItems.some((item) => item.type === "none")) {
    labels.value = []
    return
  }
  labels.value = nextItems.filter((item): item is LabelOption => item.type === "label")
}
</script>

<template>
  <Field class="max-w-xs">
    <Combobox
      multiple
      :by="(a, b) => (a as LabelSelectionItem).id === (b as LabelSelectionItem).id"
      :model-value="labels"
      @update:model-value="(v) => handleLabelsChange(v as unknown as LabelSelectionItem[])"
    >
      <ComboboxTrigger
        :class="cn(buttonVariants({ variant: 'outline' }), 'w-full justify-between font-normal')"
      >
        <ComboboxValue v-slot="{ value }">
          <component :is="() => labelTriggerSummary((value as LabelOption[]) ?? [])" />
        </ComboboxValue>
      </ComboboxTrigger>

      <ComboboxContent class="max-w-(--anchor-width) min-w-(--anchor-width)">
        <ComboboxInput :show-trigger="false" placeholder="Select labels" class="mb-1" />
        <ComboboxEmpty>No labels found.</ComboboxEmpty>
        <ComboboxList>
          <ComboboxItem :value="noLabelOption">
            <component :is="() => labelOptionRow(noLabelOption)" />
          </ComboboxItem>
          <ComboboxSeparator />

          <ComboboxItem v-for="item in labelOptions" :key="item.id" :value="item">
            <component :is="() => labelOptionRow(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-26.json

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

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