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>