@octabits-io/nuxt-ui-kit 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,651 @@
1
+ <script setup lang="ts">
2
+ // Shipped as source: the consumer's Vite compiles this SFC. All imports are
3
+ // explicit — no reliance on the consumer's auto-import configuration.
4
+ // i18n key contract: dateRange.* (checkIn/checkOut/errors.*/availability*/
5
+ // atTime/nextDay/checking) and period.travel.nights / period.booking.days.
6
+ import { computed, ref, watch } from 'vue'
7
+ import { useI18n } from 'vue-i18n'
8
+ import { CalendarDate } from '@internationalized/date'
9
+ import type { DateValue } from '@internationalized/date'
10
+ import { addDays, differenceInDays, eachDayOfInterval, format, parseISO } from 'date-fns'
11
+ import UInputDate from '@nuxt/ui/components/InputDate.vue'
12
+ import UPopover from '@nuxt/ui/components/Popover.vue'
13
+ import UButton from '@nuxt/ui/components/Button.vue'
14
+ import UCalendar from '@nuxt/ui/components/Calendar.vue'
15
+ import UIcon from '@nuxt/ui/components/Icon.vue'
16
+ import {
17
+ calculateDays,
18
+ createDateFormatter,
19
+ shiftIso,
20
+ type Period,
21
+ } from '@octabits-io/nuxt-ui-kit/dates'
22
+
23
+ type AvailabilityStatus = {
24
+ status: 'available' | 'unavailable' | 'partial'
25
+ conflictDates?: string[]
26
+ message?: string
27
+ }
28
+
29
+ type InputSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
30
+
31
+ const props = withDefaults(defineProps<{
32
+ modelValue: Period
33
+ minDays?: number
34
+ maxDays?: number
35
+ disabled?: boolean
36
+ size?: InputSize
37
+ /** Calendar trigger icon. Defaults to a plain calendar; pass e.g. `i-lucide-calendar-check` for booking/occupancy ranges. */
38
+ icon?: string
39
+ startLabel?: string
40
+ endLabel?: string
41
+ availabilityCheck?: (period: Period) => Promise<AvailabilityStatus>
42
+ blockedDates?: string[]
43
+ softBlockedDates?: string[]
44
+ checkInTime?: string
45
+ checkOutTime?: string
46
+ /**
47
+ * Date semantics of the *end* input, matching `PeriodDisplay`.
48
+ *
49
+ * `booking` (default) → the end input shows the bound period's `end`
50
+ * directly (inclusive last booked day); span counted in days.
51
+ *
52
+ * `travel` → the end input and its calendar show the **departure date**
53
+ * (`end` + 1 day); span counted in nights. The bound `modelValue` stays in
54
+ * booking semantics — conversion happens only at the input boundary.
55
+ */
56
+ kind?: 'travel' | 'booking'
57
+ }>(), {
58
+ minDays: 1,
59
+ maxDays: undefined,
60
+ disabled: false,
61
+ size: 'md',
62
+ icon: 'i-lucide-calendar',
63
+ startLabel: undefined,
64
+ endLabel: undefined,
65
+ availabilityCheck: undefined,
66
+ blockedDates: () => [],
67
+ softBlockedDates: () => [],
68
+ checkInTime: undefined,
69
+ checkOutTime: undefined,
70
+ kind: 'booking',
71
+ })
72
+
73
+ const emit = defineEmits<{
74
+ 'update:modelValue': [value: Period]
75
+ 'change': [payload: { period: Period; isValid: boolean }]
76
+ 'availability': [status: AvailabilityStatus]
77
+ }>()
78
+
79
+ const { t, locale } = useI18n()
80
+ const { formatDate, formatDateMedium } = createDateFormatter({ getLocale: () => locale.value })
81
+
82
+ const touched = ref(false)
83
+ const startPopoverOpen = ref(false)
84
+ const endPopoverOpen = ref(false)
85
+
86
+ // ISO (YYYY-MM-DD) of the day under the cursor in whichever calendar is open.
87
+ // Only one popover is open at a time, so a single shared ref suffices. Drives
88
+ // the live span preview/count; cleared when both popovers close.
89
+ const hoveredDay = ref<string | null>(null)
90
+ watch([startPopoverOpen, endPopoverOpen], ([s, e]) => {
91
+ if (!s && !e) hoveredDay.value = null
92
+ })
93
+
94
+ const startPopoverTitle = computed(() => props.startLabel || t('dateRange.checkIn'))
95
+ const endPopoverTitle = computed(() => props.endLabel || t('dateRange.checkOut'))
96
+
97
+ // In travel mode the end input/calendar shows the departure date = booking
98
+ // end + 1 day. The bound modelValue always stays in booking semantics.
99
+ const travelOffset = computed(() => (props.kind === 'travel' ? 1 : 0))
100
+
101
+ /** The end date as displayed in the end input/calendar (departure in travel mode). */
102
+ const displayEnd = computed(() => shiftIso(props.modelValue.end, travelOffset.value))
103
+
104
+ // reka-ui's UCalendar emits a wider union (DateValue | DateRange | DateValue[]
105
+ // | null | undefined) than what we use. Accept it and narrow to CalendarDate.
106
+ type CalendarEmitValue = DateValue | DateValue[] | { start?: DateValue, end?: DateValue } | null | undefined
107
+
108
+ // UPSTREAM BUG (nuxt/ui ≥ 4.9.0): UCalendar accepts `default-placeholder` but
109
+ // never reads it — the month/year view-switching feature (nuxt/ui#6582) moved
110
+ // placeholder state into a local ref initialized from `placeholder` →
111
+ // `modelValue` → `defaultValue` → today, so an empty calendar always opens on
112
+ // the current month. Both popover calendars below therefore bind `:placeholder`
113
+ // (initial value only; internal month navigation still works) so the empty end
114
+ // calendar opens on the start date's month and vice versa. Revert to
115
+ // `:default-placeholder` once fixed upstream.
116
+
117
+ function commitPeriod(start: string, end: string) {
118
+ touched.value = true
119
+ emit('update:modelValue', { start, end })
120
+ }
121
+
122
+ function onStartCalendarSelect(v: CalendarEmitValue) {
123
+ const date = v instanceof CalendarDate ? v : undefined
124
+ startPopoverOpen.value = false
125
+ if (!date) {
126
+ startDate.value = undefined
127
+ return
128
+ }
129
+ const iso = date.toString()
130
+ const end = props.modelValue.end
131
+ // Ordering checks run in display (travel) space so a departure date never
132
+ // ends up before the arrival date.
133
+ if (end && iso >= shiftIso(end, travelOffset.value) && travelOffset.value > 0) {
134
+ // Picking a "start" on/after the existing departure: re-anchor the range
135
+ // to start on the picked date, clearing the end for re-selection.
136
+ commitPeriod(iso, '')
137
+ return
138
+ }
139
+ if (end && iso > end && travelOffset.value === 0) {
140
+ // Picking a "start" later than the existing end: order the two instead of
141
+ // leaving an invalid range — the picked date becomes the end, the old end
142
+ // becomes the start.
143
+ commitPeriod(end, iso)
144
+ return
145
+ }
146
+ startDate.value = date
147
+ if (!end) {
148
+ // Defer past the current event loop tick: the click that selected the
149
+ // start date is still bubbling, and reka-ui's outside-click detector
150
+ // would otherwise immediately dismiss the freshly-opened end popover.
151
+ // We need a delay long enough for both the start popover's exit transition
152
+ // and reka-ui's DismissableLayer teardown to finish.
153
+ setTimeout(() => {
154
+ endPopoverOpen.value = true
155
+ }, 150)
156
+ }
157
+ }
158
+
159
+ function onEndCalendarSelect(v: CalendarEmitValue) {
160
+ const date = v instanceof CalendarDate ? v : undefined
161
+ endPopoverOpen.value = false
162
+ if (!date) {
163
+ endDate.value = undefined
164
+ return
165
+ }
166
+ // In travel mode the picked day is the departure date; the bound end is the
167
+ // last booked day (departure − 1).
168
+ const iso = date.toString()
169
+ const start = props.modelValue.start
170
+ if (start && iso === start && travelOffset.value > 0) {
171
+ // Departure on the arrival day is a zero-night stay — ignore.
172
+ return
173
+ }
174
+ if (start && iso < start) {
175
+ // Picking an "end" earlier than the existing start: order the two instead
176
+ // of leaving an invalid range — the picked date becomes the start, the old
177
+ // start becomes the end (converted back to booking semantics).
178
+ commitPeriod(iso, shiftIso(start, -travelOffset.value))
179
+ return
180
+ }
181
+ endDate.value = date
182
+ }
183
+
184
+ // --- ISO ↔ CalendarDate bridge ---
185
+
186
+ function isoToCalendar(iso: string): CalendarDate | undefined {
187
+ if (!iso) return undefined
188
+ const [y, m, d] = iso.split('-').map(Number)
189
+ if (!y || !m || !d) return undefined
190
+ return new CalendarDate(y, m, d)
191
+ }
192
+ function dateValueToIso(v: DateValue | undefined | null): string {
193
+ return v ? v.toString() : ''
194
+ }
195
+
196
+ const startDate = computed<CalendarDate | undefined>({
197
+ get: () => isoToCalendar(props.modelValue.start),
198
+ set: (v) => {
199
+ touched.value = true
200
+ emit('update:modelValue', { start: dateValueToIso(v), end: props.modelValue.end })
201
+ },
202
+ })
203
+ const endDate = computed<CalendarDate | undefined>({
204
+ get: () => isoToCalendar(displayEnd.value),
205
+ set: (v) => {
206
+ touched.value = true
207
+ emit('update:modelValue', {
208
+ start: props.modelValue.start,
209
+ end: shiftIso(dateValueToIso(v), -travelOffset.value),
210
+ })
211
+ },
212
+ })
213
+
214
+ // --- Blocked-date predicate (used by both inputs & their popover calendars) ---
215
+
216
+ const blockedSet = computed(() => new Set(props.blockedDates))
217
+ const softBlockedSet = computed(() => new Set(props.softBlockedDates))
218
+ function isDateDisabled(date: DateValue): boolean {
219
+ return blockedSet.value.has(date.toString())
220
+ }
221
+ function isDateSoftBlocked(date: DateValue): boolean {
222
+ const iso = date.toString()
223
+ return softBlockedSet.value.has(iso) && !blockedSet.value.has(iso)
224
+ }
225
+ // End-side predicates run in display space: in travel mode a departure day D
226
+ // only occupies the night up to D − 1, so D is selectable iff D − 1 is free.
227
+ // This keeps "checkout-only" days (the start day of the next booking) pickable
228
+ // as departure dates for back-to-back stays.
229
+ function isEndDateDisabled(date: DateValue): boolean {
230
+ return blockedSet.value.has(shiftIso(date.toString(), -travelOffset.value))
231
+ }
232
+ function isEndDateSoftBlocked(date: DateValue): boolean {
233
+ const iso = shiftIso(date.toString(), -travelOffset.value)
234
+ return softBlockedSet.value.has(iso) && !blockedSet.value.has(iso)
235
+ }
236
+
237
+ // --- Range preview (anchor + live span) shown inside the calendar popovers ---
238
+ //
239
+ // Each single-date calendar only knows its own date, so the *other* endpoint
240
+ // was previously invisible. We mark it (the "anchor") and shade the span from
241
+ // it to the day being chosen (the selected opposite date, or — while picking —
242
+ // the hovered day). ISO YYYY-MM-DD strings sort chronologically, so plain
243
+ // string comparison gives the ordering without CalendarDate.compare.
244
+
245
+ type CalendarSide = 'start' | 'end'
246
+ type DayRole = 'anchor' | 'target' | 'inRange' | null
247
+
248
+ // For the end calendar the anchor is the start date (and vice versa); the
249
+ // target is the hovered day, falling back to this calendar's own date. All
250
+ // dates here are in display space — the end side shows the departure date in
251
+ // travel mode.
252
+ function anchorFor(side: CalendarSide): string | undefined {
253
+ return (side === 'end' ? props.modelValue.start : displayEnd.value) || undefined
254
+ }
255
+ function targetFor(side: CalendarSide): string | undefined {
256
+ // Hover wins so re-editing an already-set endpoint previews the new candidate
257
+ // span live; falls back to this calendar's own committed date at rest.
258
+ const own = side === 'end' ? displayEnd.value : props.modelValue.start
259
+ return hoveredDay.value || own || undefined
260
+ }
261
+
262
+ function dayRole(dayIso: string, anchor?: string, target?: string): DayRole {
263
+ if (!anchor) return null
264
+ if (dayIso === anchor) return 'anchor'
265
+ if (!target || target === anchor) return null
266
+ const [lo, hi] = anchor <= target ? [anchor, target] : [target, anchor]
267
+ if (dayIso === target) return 'target'
268
+ if (dayIso > lo && dayIso < hi) return 'inRange'
269
+ return null
270
+ }
271
+
272
+ const dayRoleClasses: Record<Exclude<DayRole, null>, string> = {
273
+ anchor: 'bg-primary text-inverted font-medium',
274
+ target: 'bg-primary/30 text-primary font-medium',
275
+ inRange: 'bg-primary/15 text-primary',
276
+ }
277
+ const PILL_BASE = 'inline-flex items-center justify-center rounded px-1.5'
278
+
279
+ // Full pill classes for a day cell. Soft-block (amber) wins over range styling;
280
+ // '' leaves the plain number (no padding, unchanged from before).
281
+ function dayPillClass(date: DateValue, side: CalendarSide): string {
282
+ const softBlocked = side === 'end' ? isEndDateSoftBlocked(date) : isDateSoftBlocked(date)
283
+ if (softBlocked) return `${PILL_BASE} bg-warning/20 text-warning line-through`
284
+ const iso = date.toString()
285
+ // This calendar's own selected date is rendered natively by UCalendar (solid
286
+ // primary circle with contrast text). Don't override its colour, or the
287
+ // number disappears (text-primary on a primary background).
288
+ const own = side === 'end' ? displayEnd.value : props.modelValue.start
289
+ if (iso === own) return ''
290
+ const role = dayRole(iso, anchorFor(side), targetFor(side))
291
+ return role ? `${PILL_BASE} ${dayRoleClasses[role]}` : ''
292
+ }
293
+
294
+ // Live "N nights/days" label for the popover header — only while both the
295
+ // anchor and a target (selected or hovered) exist. Count + wording mirror
296
+ // PeriodDisplay so the header matches the period shown alongside the input.
297
+ function spanLabel(side: CalendarSide): string {
298
+ const anchor = anchorFor(side)
299
+ const target = targetFor(side)
300
+ if (!anchor || !target || anchor === target) return ''
301
+ const [start, end] = anchor <= target ? [anchor, target] : [target, anchor]
302
+ if (props.kind === 'travel') {
303
+ // Display space: `end` is already the departure date.
304
+ const nights = differenceInDays(new Date(end), new Date(start))
305
+ if (nights <= 0) return ''
306
+ return t('period.travel.nights', nights)
307
+ }
308
+ const days = calculateDays({ start, end }) // inclusive booked days
309
+ if (days <= 0) return ''
310
+ return t('period.booking.days', days)
311
+ }
312
+
313
+ // Compute blocked dates that fall inside the selected range (excluding the
314
+ // endpoints themselves — those are reported as a more specific error).
315
+ const blockedInsideRange = computed<string[]>(() => {
316
+ const { start, end } = props.modelValue
317
+ if (!start || !end || end < start) return []
318
+ if (props.blockedDates.length === 0) return []
319
+ try {
320
+ return eachDayOfInterval({ start: parseISO(start), end: parseISO(end) })
321
+ .map(d => format(d, 'yyyy-MM-dd'))
322
+ .filter(iso => blockedSet.value.has(iso) && iso !== start && iso !== end)
323
+ } catch {
324
+ return []
325
+ }
326
+ })
327
+
328
+ const startIsBlocked = computed(() =>
329
+ !!props.modelValue.start && blockedSet.value.has(props.modelValue.start),
330
+ )
331
+ const endIsBlocked = computed(() =>
332
+ !!props.modelValue.end && blockedSet.value.has(props.modelValue.end),
333
+ )
334
+
335
+ const errorMessage = computed<string | null>(() => {
336
+ const { start, end } = props.modelValue
337
+ if (!touched.value && !start && !end) return null
338
+ if (!start || !end) return t('dateRange.errors.bothRequired')
339
+ if (end < start) return t('dateRange.errors.endAfterStart')
340
+
341
+ const days = calculateDays({ start, end })
342
+ if (props.minDays > 1 && days < props.minDays) {
343
+ return t('dateRange.errors.minDays', { n: props.minDays })
344
+ }
345
+ if (props.maxDays !== undefined && days > props.maxDays) {
346
+ return t('dateRange.errors.maxDays', { n: props.maxDays })
347
+ }
348
+
349
+ if (startIsBlocked.value) {
350
+ return t('dateRange.errors.blockedDate', { date: formatDate(start) })
351
+ }
352
+ if (endIsBlocked.value) {
353
+ return t('dateRange.errors.blockedDate', { date: formatDate(end) })
354
+ }
355
+ if (blockedInsideRange.value.length > 0) {
356
+ return t('dateRange.errors.blockedRange', {
357
+ dates: blockedInsideRange.value.map(formatDate).join(', '),
358
+ })
359
+ }
360
+
361
+ return null
362
+ })
363
+
364
+ const isValid = computed(() => errorMessage.value === null
365
+ && !!props.modelValue.start
366
+ && !!props.modelValue.end)
367
+
368
+ const inputColor = computed<'error' | undefined>(() =>
369
+ errorMessage.value !== null && touched.value ? 'error' : undefined,
370
+ )
371
+
372
+ // Mark hard-blocked cells in the calendar popover. reka-ui sets data-disabled
373
+ // when is-date-disabled returns true (hard block, unselectable). Soft-blocked
374
+ // dates are painted via the #day slot below so the cell stays clickable —
375
+ // passing them to is-date-unavailable would prevent selection.
376
+ const calendarUi = {
377
+ cellTrigger:
378
+ 'data-[disabled]:!bg-error/15 data-[disabled]:!text-error data-[disabled]:line-through data-[disabled]:!opacity-100',
379
+ }
380
+
381
+ // --- Availability check ---
382
+
383
+ const availabilityStatus = ref<AvailabilityStatus | null>(null)
384
+ const availabilityLoading = ref(false)
385
+
386
+ // Minimal trailing-edge debounce — avoids a @vueuse/core peer for one helper.
387
+ function debounce<A extends unknown[]>(fn: (...args: A) => void, ms: number) {
388
+ let timer: ReturnType<typeof setTimeout> | undefined
389
+ return (...args: A) => {
390
+ clearTimeout(timer)
391
+ timer = setTimeout(() => fn(...args), ms)
392
+ }
393
+ }
394
+
395
+ const runAvailabilityCheck = debounce(async (period: Period) => {
396
+ if (!props.availabilityCheck) return
397
+ availabilityLoading.value = true
398
+ try {
399
+ const result = await props.availabilityCheck(period)
400
+ availabilityStatus.value = result
401
+ emit('availability', result)
402
+ } catch (err) {
403
+ availabilityStatus.value = null
404
+ console.warn('[DateRangeInput] availabilityCheck failed', err)
405
+ } finally {
406
+ availabilityLoading.value = false
407
+ }
408
+ }, 300)
409
+
410
+ watch(
411
+ () => [props.modelValue.start, props.modelValue.end, props.availabilityCheck] as const,
412
+ ([start, end, check]) => {
413
+ if (!check) {
414
+ availabilityStatus.value = null
415
+ return
416
+ }
417
+ if (!isValid.value) {
418
+ availabilityStatus.value = null
419
+ availabilityLoading.value = false
420
+ return
421
+ }
422
+ runAvailabilityCheck({ start, end })
423
+ },
424
+ { immediate: true },
425
+ )
426
+
427
+ watch(
428
+ () => ({ ...props.modelValue }),
429
+ (period) => {
430
+ emit('change', { period, isValid: isValid.value })
431
+ },
432
+ )
433
+
434
+ const availabilityIcon = computed(() => {
435
+ switch (availabilityStatus.value?.status) {
436
+ case 'available': return 'i-lucide-check-circle-2'
437
+ case 'unavailable': return 'i-lucide-alert-circle'
438
+ case 'partial': return 'i-lucide-alert-triangle'
439
+ default: return ''
440
+ }
441
+ })
442
+
443
+ const availabilityColor = computed(() => {
444
+ switch (availabilityStatus.value?.status) {
445
+ case 'available': return 'text-success'
446
+ case 'unavailable': return 'text-error'
447
+ case 'partial': return 'text-warning'
448
+ default: return ''
449
+ }
450
+ })
451
+
452
+ const availabilityMessage = computed(() => {
453
+ const s = availabilityStatus.value
454
+ if (!s) return ''
455
+ if (s.message) return s.message
456
+ switch (s.status) {
457
+ case 'available': return t('dateRange.availabilityOk')
458
+ case 'unavailable': return t('dateRange.availabilityConflict')
459
+ case 'partial': return t('dateRange.availabilityPartial')
460
+ }
461
+ })
462
+
463
+ // --- Check-in / check-out time hint ---
464
+
465
+ const showTimeHint = computed(() =>
466
+ props.checkInTime !== undefined || props.checkOutTime !== undefined,
467
+ )
468
+
469
+ /** Customer-facing checkout date: the day after the (inclusive) last booked day. */
470
+ function checkoutDateIso(end: string): string {
471
+ if (!end) return ''
472
+ try {
473
+ return format(addDays(parseISO(end), 1), 'yyyy-MM-dd')
474
+ } catch {
475
+ return ''
476
+ }
477
+ }
478
+
479
+ const checkInHint = computed(() => {
480
+ if (!props.checkInTime) return ''
481
+ const start = props.modelValue.start
482
+ if (start) {
483
+ return `${t('dateRange.checkIn')}: ${t('dateRange.atTime', {
484
+ date: formatDateMedium(start),
485
+ time: props.checkInTime,
486
+ })}`
487
+ }
488
+ return `${t('dateRange.checkIn')} ${props.checkInTime}`
489
+ })
490
+
491
+ const checkOutHint = computed(() => {
492
+ if (!props.checkOutTime) return ''
493
+ const end = props.modelValue.end
494
+ // In travel mode the end input already shows the departure date, so the
495
+ // "(next day)" clarifier is only useful for booking-semantics inputs.
496
+ const suffix = props.kind === 'booking' ? ` ${t('dateRange.nextDay')}` : ''
497
+ if (end) {
498
+ const iso = checkoutDateIso(end)
499
+ return `${t('dateRange.checkOut')}: ${t('dateRange.atTime', {
500
+ date: iso ? formatDateMedium(iso) : '',
501
+ time: props.checkOutTime,
502
+ })}${suffix}`
503
+ }
504
+ return `${t('dateRange.checkOut')} ${props.checkOutTime}${suffix}`
505
+ })
506
+
507
+ const timeHintParts = computed(() =>
508
+ [checkInHint.value, checkOutHint.value].filter(Boolean),
509
+ )
510
+ </script>
511
+
512
+ <template>
513
+ <div class="flex flex-col gap-1">
514
+ <div class="flex items-center gap-2">
515
+ <UInputDate
516
+ v-model="startDate"
517
+ :is-date-disabled="isDateDisabled"
518
+ :size="size"
519
+ :disabled="disabled"
520
+ :color="inputColor"
521
+ :aria-label="startLabel"
522
+ class="flex-1"
523
+ >
524
+ <template #trailing>
525
+ <UPopover v-model:open="startPopoverOpen">
526
+ <UButton
527
+ color="neutral"
528
+ variant="link"
529
+ size="sm"
530
+ :icon="icon"
531
+ :aria-label="startLabel"
532
+ :disabled="disabled"
533
+ class="px-0"
534
+ />
535
+ <template #content>
536
+ <div class="flex flex-col" @pointerleave="hoveredDay = null">
537
+ <p class="flex items-baseline justify-between gap-2 px-3 pt-2 pb-1">
538
+ <span class="text-xs font-medium text-muted uppercase tracking-wide">
539
+ {{ startPopoverTitle }}
540
+ </span>
541
+ <span v-if="spanLabel('start')" class="text-xs font-medium text-primary">
542
+ {{ spanLabel('start') }}
543
+ </span>
544
+ </p>
545
+ <UCalendar
546
+ :model-value="startDate"
547
+ :placeholder="startDate ?? endDate"
548
+ :is-date-disabled="isDateDisabled"
549
+ :ui="calendarUi"
550
+ class="p-2"
551
+ @update:model-value="onStartCalendarSelect"
552
+ >
553
+ <template #day="{ day }">
554
+ <span :class="dayPillClass(day, 'start')" @pointerenter="hoveredDay = day.toString()">
555
+ {{ day.day }}
556
+ </span>
557
+ </template>
558
+ </UCalendar>
559
+ </div>
560
+ </template>
561
+ </UPopover>
562
+ </template>
563
+ </UInputDate>
564
+
565
+ <span class="text-muted shrink-0" aria-hidden="true">→</span>
566
+
567
+ <UInputDate
568
+ v-model="endDate"
569
+ :is-date-disabled="isEndDateDisabled"
570
+ :size="size"
571
+ :disabled="disabled"
572
+ :color="inputColor"
573
+ :aria-label="endLabel"
574
+ class="flex-1"
575
+ >
576
+ <template #trailing>
577
+ <UPopover v-model:open="endPopoverOpen">
578
+ <UButton
579
+ color="neutral"
580
+ variant="link"
581
+ size="sm"
582
+ :icon="icon"
583
+ :aria-label="endLabel"
584
+ :disabled="disabled"
585
+ class="px-0"
586
+ />
587
+ <template #content>
588
+ <div class="flex flex-col" @pointerleave="hoveredDay = null">
589
+ <p class="flex items-baseline justify-between gap-2 px-3 pt-2 pb-1">
590
+ <span class="text-xs font-medium text-muted uppercase tracking-wide">
591
+ {{ endPopoverTitle }}
592
+ </span>
593
+ <span v-if="spanLabel('end')" class="text-xs font-medium text-primary">
594
+ {{ spanLabel('end') }}
595
+ </span>
596
+ </p>
597
+ <UCalendar
598
+ :model-value="endDate"
599
+ :placeholder="endDate ?? startDate"
600
+ :is-date-disabled="isEndDateDisabled"
601
+ :ui="calendarUi"
602
+ class="p-2"
603
+ @update:model-value="onEndCalendarSelect"
604
+ >
605
+ <template #day="{ day }">
606
+ <span :class="dayPillClass(day, 'end')" @pointerenter="hoveredDay = day.toString()">
607
+ {{ day.day }}
608
+ </span>
609
+ </template>
610
+ </UCalendar>
611
+ </div>
612
+ </template>
613
+ </UPopover>
614
+ </template>
615
+ </UInputDate>
616
+ </div>
617
+
618
+ <!-- Slot for a derived summary (e.g. the resulting travel period) shown
619
+ between the inputs and the time/availability hints. -->
620
+ <slot name="summary" />
621
+
622
+ <p v-if="showTimeHint && timeHintParts.length" class="text-xs text-muted">
623
+ {{ timeHintParts.join(' • ') }}
624
+ </p>
625
+
626
+ <p v-if="errorMessage" class="text-sm text-error">
627
+ {{ errorMessage }}
628
+ </p>
629
+
630
+ <div
631
+ v-else-if="availabilityCheck && (availabilityLoading || availabilityStatus)"
632
+ class="flex items-center gap-1.5 text-sm"
633
+ :class="availabilityLoading ? 'text-muted' : availabilityColor"
634
+ >
635
+ <template v-if="availabilityLoading">
636
+ <UIcon name="i-lucide-loader-2" class="animate-spin" />
637
+ <span>{{ t('dateRange.checking') }}</span>
638
+ </template>
639
+ <template v-else-if="availabilityStatus">
640
+ <UIcon :name="availabilityIcon" />
641
+ <span>{{ availabilityMessage }}</span>
642
+ <span
643
+ v-if="availabilityStatus.status === 'partial' && availabilityStatus.conflictDates?.length"
644
+ class="text-muted"
645
+ >
646
+ ({{ availabilityStatus.conflictDates.map(formatDate).join(', ') }})
647
+ </span>
648
+ </template>
649
+ </div>
650
+ </div>
651
+ </template>