combobox

A status change combobox

A status change combobox

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

src/combobox/c-combobox-21.vue

<!--
  `IconPlaceholder` заменён инлайновым `<svg>` (пути lucide-react v0.545.0:
  "circle-dot"/"circle"/"circle-pause"/"circle-check"/"circle-x").
  `StatusGlyph`/`StatusLabel` реализованы как фабрики `VNodeChild`,
  рендерящиеся через `<component :is="() => fn(...)" />` — тот же приём,
  что и `field.icon` в `reui/filters` (см. docs/PORTING.md), нужен потому
  что один и тот же `VNode` нельзя монтировать в двух местах разметки
  (триггер и пункт списка) одновременно.
  `ComboboxTrigger`-as-`Button` — тот же приём, что и в `c-combobox-10.vue`.
  `placeholder` на `ComboboxValue` не поддержан портом (см. `ComboboxValue.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, ComboboxTrigger, ComboboxValue } from "@/components/ui/combobox"
import { Field } from "@/components/ui/field"
import { cn } from "@/lib/utils"

const statusOptions = [
  { value: "backlog", label: "Backlog", tone: "backlog" },
  { value: "planned", label: "Planned", tone: "planned" },
  { value: "in-progress", label: "In Progress", tone: "inProgress" },
  { value: "paused", label: "Paused", tone: "paused" },
  { value: "completed", label: "Completed", tone: "completed" },
  { value: "cancelled", label: "Cancelled", tone: "cancelled" },
] as const

type StatusOption = (typeof statusOptions)[number]

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 statusGlyph(tone: StatusOption["tone"]): VNodeChild {
  switch (tone) {
    case "backlog":
      return h(
        "svg",
        { ...svgAttrs, class: "text-muted-foreground/70! **:text-muted-foreground/70! size-4 shrink-0" },
        [h("circle", { cx: "12", cy: "12", r: "10" }), h("circle", { cx: "12", cy: "12", r: "1" })]
      )
    case "planned":
      return h(
        "svg",
        { ...svgAttrs, class: "text-foreground/70! [&_*]:text-foreground/70! size-4 shrink-0" },
        [h("circle", { cx: "12", cy: "12", r: "10" })]
      )
    case "inProgress":
      return h(
        "svg",
        { ...svgAttrs, class: "size-4 shrink-0 text-sky-500! [&_*]:text-sky-500!" },
        [h("circle", { cx: "12", cy: "12", r: "10" }), h("circle", { cx: "12", cy: "12", r: "1" })]
      )
    case "paused":
      return h(
        "svg",
        { ...svgAttrs, class: "size-4 shrink-0 text-amber-500! [&_*]:text-amber-500!" },
        [
          h("circle", { cx: "12", cy: "12", r: "10" }),
          h("line", { x1: "10", x2: "10", y1: "15", y2: "9" }),
          h("line", { x1: "14", x2: "14", y1: "15", y2: "9" }),
        ]
      )
    case "completed":
      return h(
        "svg",
        { ...svgAttrs, class: "size-4 shrink-0 text-emerald-500! [&_*]:text-emerald-500!" },
        [h("circle", { cx: "12", cy: "12", r: "10" }), h("path", { d: "m9 12 2 2 4-4" })]
      )
    case "cancelled":
      return h(
        "svg",
        { ...svgAttrs, class: "text-muted-foreground! [&_*]:text-muted-foreground! size-4 shrink-0" },
        [
          h("circle", { cx: "12", cy: "12", r: "10" }),
          h("path", { d: "m15 9-6 6" }),
          h("path", { d: "m9 9 6 6" }),
        ]
      )
  }
}

function statusLabel(status: StatusOption): VNodeChild {
  return h("span", { class: "flex min-w-0 items-center gap-2" }, [
    statusGlyph(status.tone),
    h("span", { class: "truncate" }, status.label),
  ])
}

const status = ref<StatusOption | null>(statusOptions[1])
</script>

<template>
  <Field class="max-w-xs">
    <Combobox
      :model-value="status"
      @update:model-value="(v) => (status = v as StatusOption | null)"
    >
      <ComboboxTrigger
        :class="cn(buttonVariants({ variant: 'outline' }), 'w-full justify-between font-normal')"
      >
        <ComboboxValue v-slot="{ value }">
          <component :is="() => ((value as StatusOption | null) ? statusLabel(value as StatusOption) : h('span', { class: 'text-muted-foreground' }, 'Set status'))" />
        </ComboboxValue>
      </ComboboxTrigger>

      <ComboboxContent class="max-w-(--anchor-width) min-w-(--anchor-width)">
        <ComboboxInput :show-trigger="false" placeholder="Change status" class="mb-1" />
        <ComboboxEmpty>No statuses found.</ComboboxEmpty>
        <ComboboxList>
          <ComboboxItem v-for="item in statusOptions" :key="item.value" :value="item">
            <component :is="() => statusLabel(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-21.json

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

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