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>

Установка

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

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

npm-зависимости

  • date-fns

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