combobox
A date selection combobox with an optional custom picker dialog
A date selection combobox with an optional custom picker dialog
Загрузка превью…
src/combobox/c-combobox-25.vue
<!--
`IconPlaceholder` заменён инлайновым `<svg>` (lucide-react v0.545.0:
"calendar", "calendar-search"). `Combobox`-триггер собран тем же приёмом
`cn(buttonVariants(...), ...)`, что и в `c-combobox-10.vue`.
-->
<script setup lang="ts">
import { computed, h, ref, watch, type VNodeChild } from "vue"
import {
addDays,
addMonths,
addWeeks,
endOfMonth,
endOfWeek,
format,
isSameDay,
startOfDay,
} from "date-fns"
import { buttonVariants, Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Combobox, ComboboxContent, ComboboxItem, ComboboxList, ComboboxSeparator, ComboboxTrigger } from "@/components/ui/combobox"
import { Field } from "@/components/ui/field"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
type PresetDateOption = { type: "preset"; id: string; label: string; date: Date; searchText: string }
type CustomDateAction = { type: "custom"; id: "custom-date"; label: string; searchText: string }
type EmptyDateOption = { type: "none"; id: "no-date"; label: string; searchText: string }
type DateOption = EmptyDateOption | PresetDateOption | CustomDateAction
const noDateOption: EmptyDateOption = {
type: "none",
id: "no-date",
label: "No date",
searchText: "No date clear date remove date empty",
}
const customDateOption: CustomDateAction = {
type: "custom",
id: "custom-date",
label: "Custom date...",
searchText: "Custom date custom day calendar picker choose a date manual date",
}
type DateSelectionValue =
| { type: "preset"; id: string; label: string; date: Date }
| { type: "custom"; label: string; date: Date }
| null
function calendarGlyph(): 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: "M8 2v4" }),
h("path", { d: "M16 2v4" }),
h("rect", { width: "18", height: "18", x: "3", y: "4", rx: "2" }),
h("path", { d: "M3 10h18" }),
]
)
}
function dateTriggerLabel(value: DateSelectionValue, placeholder: string): VNodeChild {
if (!value) {
return h("span", { class: "flex min-w-0 items-center gap-2" }, [
calendarGlyph(),
h("span", { class: "text-muted-foreground truncate" }, placeholder),
])
}
const displayLabel = value.type === "custom" ? format(value.date, "MMM d") : value.label
return h("span", { class: "flex min-w-0 items-center gap-2" }, [
calendarGlyph(),
h("span", { class: "truncate" }, displayLabel),
])
}
function dateOptionRow(option: DateOption): VNodeChild {
if (option.type === "none" || option.type === "custom") {
return h("span", { class: "flex min-w-0 items-center gap-2" }, [
calendarGlyph(),
h("span", { class: "truncate" }, option.label),
])
}
return h("span", { class: "flex w-full min-w-0 items-center justify-between gap-3" }, [
h("span", { class: "flex min-w-0 items-center gap-2" }, [
calendarGlyph(),
h("span", { class: "truncate" }, option.label),
]),
h("span", { class: "text-muted-foreground shrink-0 text-[13px] tabular-nums" }, format(option.date, "dd/MM/yyyy")),
])
}
function getUpcomingWeekEnd(today: Date) {
const weekEnd = endOfWeek(today, { weekStartsOn: 1 })
return isSameDay(today, weekEnd) ? addWeeks(weekEnd, 1) : weekEnd
}
function getUpcomingMonthEnd(today: Date) {
const monthEnd = endOfMonth(today)
return isSameDay(today, monthEnd) ? endOfMonth(addMonths(today, 1)) : monthEnd
}
const startDate = ref<DateSelectionValue>(null)
const today = startOfDay(new Date())
const pickerOpen = ref(false)
const draftDate = ref<Date | null>(startDate.value?.date ?? addDays(today, 1))
const presetOptions = computed<PresetDateOption[]>(() => [
{ type: "preset", id: "tomorrow", label: "Tomorrow", date: addDays(today, 1), searchText: "Tomorrow next day due date" },
{ type: "preset", id: "end-of-week", label: "End of week", date: getUpcomingWeekEnd(today), searchText: "End of week Sunday week close target" },
{ type: "preset", id: "in-one-week", label: "In one week", date: addWeeks(today, 1), searchText: "In one week next week seven days target" },
{ type: "preset", id: "end-of-month", label: "End of month", date: getUpcomingMonthEnd(today), searchText: "End of month month close month end target" },
{ type: "preset", id: "in-one-month", label: "In one month", date: addMonths(today, 1), searchText: "In one month next month thirty days target" },
])
const activePreset = computed<DateOption | null>(() => {
const sd = startDate.value
if (sd?.type === "preset") {
return presetOptions.value.find((option) => option.id === sd.id) ?? null
}
return !sd ? noDateOption : null
})
watch(pickerOpen, (open) => {
if (!open) return
draftDate.value = startDate.value?.date ?? addDays(today, 1)
})
function handleOptionChange(nextValue: DateOption | null) {
if (!nextValue) return
if (nextValue.type === "none") {
startDate.value = null
return
}
if (nextValue.type === "custom") {
requestAnimationFrame(() => (pickerOpen.value = true))
return
}
startDate.value = { type: "preset", id: nextValue.id, label: nextValue.label, date: nextValue.date }
}
function handleConfirmCustomDate() {
if (!draftDate.value) return
startDate.value = { type: "custom", label: format(draftDate.value, "MMM d"), date: draftDate.value }
pickerOpen.value = false
}
</script>
<template>
<Field class="max-w-xs">
<Combobox
:model-value="activePreset"
@update:model-value="(v) => handleOptionChange(v as DateOption | null)"
>
<ComboboxTrigger
:class="cn(buttonVariants({ variant: 'outline' }), 'w-full justify-between font-normal')"
>
<component :is="() => dateTriggerLabel(startDate, 'Set start')" />
</ComboboxTrigger>
<ComboboxContent class="max-w-(--anchor-width) min-w-(--anchor-width)">
<ComboboxList>
<ComboboxItem :value="noDateOption">
<component :is="() => dateOptionRow(noDateOption)" />
</ComboboxItem>
<ComboboxSeparator />
<ComboboxItem v-for="option in presetOptions" :key="option.id" :value="option">
<component :is="() => dateOptionRow(option)" />
</ComboboxItem>
<ComboboxSeparator />
<ComboboxItem :value="customDateOption">
<component :is="() => dateOptionRow(customDateOption)" />
</ComboboxItem>
</ComboboxList>
</ComboboxContent>
</Combobox>
<Dialog v-model:open="pickerOpen">
<DialogContent class="flex w-auto max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden sm:max-w-none">
<DialogHeader>
<DialogTitle>Pick a date</DialogTitle>
</DialogHeader>
<div class="py-3">
<Calendar
v-model="draftDate"
:number-of-months="2"
:show-outside-days="false"
/>
</div>
<DialogFooter>
<DialogClose as-child>
<Button variant="outline" size="sm">Dismiss</Button>
</DialogClose>
<Button size="sm" :disabled="!draftDate" @click="handleConfirmCustomDate">Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</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>