1
// Styles
2
import './calendar-with-events.sass'
3

4
// Types
5
import { VNode, VNodeData } from 'vue'
6

7
// Directives
8
import ripple from '../../../directives/ripple'
9

10
// Mixins
11
import CalendarBase from './calendar-base'
12

13
// Helpers
14
import { escapeHTML } from '../../../util/helpers'
15

16
// Util
17
import props from '../util/props'
18
import {
19
  CalendarEventOverlapModes,
20
} from '../modes'
21
import {
22
  getDayIdentifier, diffMinutes,
23
} from '../util/timestamp'
24
import {
25
  parseEvent,
26
  isEventStart,
27
  isEventOn,
28
  isEventOverlapping,
29
} from '../util/events'
30
import {
31
  CalendarTimestamp,
32
  CalendarEventParsed,
33
  CalendarEventVisual,
34
  CalendarEventColorFunction,
35
  CalendarEventNameFunction,
36
  CalendarEventTimedFunction,
37
  CalendarDaySlotScope,
38
  CalendarDayBodySlotScope,
39
  CalendarEventOverlapMode,
40
  CalendarEvent,
41
  CalendarEventCategoryFunction,
42
} from 'vuetify/types'
43

44
// Types
45
type VEventGetter<D> = (day: D) => CalendarEventParsed[]
46

47
type VEventVisualToNode<D> = (visual: CalendarEventVisual, day: D) => VNode | false
48

49
type VEventsToNodes = <D extends CalendarDaySlotScope>(
50
  day: D,
51
  getter: VEventGetter<D>,
52
  mapper: VEventVisualToNode<D>,
53
  timed: boolean) => VNode[] | undefined
54

55
type VDailyEventsMap = {
56
  [date: string]: {
57
    parent: HTMLElement
58
    more: HTMLElement | null
59
    events: HTMLElement[]
60
  }
61
}
62

63
interface VEventScopeInput {
64
  eventParsed: CalendarEventParsed
65
  day: CalendarDaySlotScope
66
  start: boolean
67
  end: boolean
68
  timed: boolean
69
}
70

71 1
const WIDTH_FULL = 100
72 1
const WIDTH_START = 95
73 1
const MINUTES_IN_DAY = 1440
74

75
/* @vue/component */
76
export default CalendarBase.extend({
77
  name: 'calendar-with-events',
78

79
  directives: {
80
    ripple,
81
  },
82

83
  props: props.events,
84

85
  computed: {
86 1
    noEvents (): boolean {
87 1
      return this.events.length === 0
88
    },
89 1
    parsedEvents (): CalendarEventParsed[] {
90 1
      return this.events.map(this.parseEvent)
91
    },
92 0
    parsedEventOverlapThreshold (): number {
93 0
      return parseInt(this.eventOverlapThreshold)
94
    },
95 1
    eventColorFunction (): CalendarEventColorFunction {
96 1
      return typeof this.eventColor === 'function'
97 1
        ? this.eventColor
98 1
        : () => (this.eventColor as string)
99
    },
100 1
    eventTimedFunction (): CalendarEventTimedFunction {
101 1
      return typeof this.eventTimed === 'function'
102 0
        ? this.eventTimed
103 1
        : event => !!event[this.eventTimed as string]
104
    },
105 0
    eventCategoryFunction (): CalendarEventCategoryFunction {
106 1
      return typeof this.eventCategory === 'function'
107 0
        ? this.eventCategory
108 0
        : event => event[this.eventCategory as string]
109
    },
110 1
    eventTextColorFunction (): CalendarEventColorFunction {
111 1
      return typeof this.eventTextColor === 'function'
112 1
        ? this.eventTextColor
113 1
        : () => this.eventTextColor as string
114
    },
115 1
    eventNameFunction (): CalendarEventNameFunction {
116 1
      return typeof this.eventName === 'function'
117 1
        ? this.eventName
118 1
        : (event, timedEvent) => escapeHTML(event.input[this.eventName as string] as string)
119
    },
120 0
    eventModeFunction (): CalendarEventOverlapMode {
121 1
      return typeof this.eventOverlapMode === 'function'
122 0
        ? this.eventOverlapMode
123 0
        : CalendarEventOverlapModes[this.eventOverlapMode]
124
    },
125 1
    eventWeekdays (): number[] {
126 1
      return this.parsedWeekdays
127
    },
128 1
    categoryMode (): boolean {
129 1
      return false
130
    },
131
  },
132

133
  methods: {
134 1
    parseEvent (input: CalendarEvent, index = 0): CalendarEventParsed {
135 1
      return parseEvent(
136
        input,
137
        index,
138
        this.eventStart,
139
        this.eventEnd,
140
        this.eventTimedFunction(input),
141 1
        this.categoryMode ? this.eventCategoryFunction(input) : false,
142
      )
143
    },
144 1
    formatTime (withTime: CalendarTimestamp, ampm: boolean): string {
145 1
      const formatter = this.getFormatter({
146
        timeZone: 'UTC',
147
        hour: 'numeric',
148 1
        minute: withTime.minute > 0 ? 'numeric' : undefined,
149
      })
150

151 1
      return formatter(withTime, true)
152
    },
153 1
    updateEventVisibility () {
154 1
      if (this.noEvents || !this.eventMore) {
155 1
        return
156
      }
157

158 0
      const eventHeight = this.eventHeight
159 0
      const eventsMap = this.getEventsMap()
160

161 0
      for (const date in eventsMap) {
162 0
        const { parent, events, more } = eventsMap[date]
163 1
        if (!more) {
164 0
          break
165
        }
166

167 0
        const parentBounds = parent.getBoundingClientRect()
168 0
        const last = events.length - 1
169 0
        let hide = false
170 0
        let hidden = 0
171

172 0
        for (let i = 0; i <= last; i++) {
173 1
          if (!hide) {
174 0
            const eventBounds = events[i].getBoundingClientRect()
175 1
            hide = i === last
176 0
              ? (eventBounds.bottom > parentBounds.bottom)
177 0
              : (eventBounds.bottom + eventHeight > parentBounds.bottom)
178
          }
179 1
          if (hide) {
180 0
            events[i].style.display = 'none'
181 0
            hidden++
182
          }
183
        }
184

185 1
        if (hide) {
186 0
          more.style.display = ''
187 0
          more.innerHTML = this.$vuetify.lang.t(this.eventMoreText, hidden)
188
        } else {
189 0
          more.style.display = 'none'
190
        }
191
      }
192
    },
193 1
    getEventsMap (): VDailyEventsMap {
194 1
      const eventsMap: VDailyEventsMap = {}
195 1
      const elements = this.$refs.events as HTMLElement[]
196

197 1
      if (!elements || !elements.forEach) {
198 0
        return eventsMap
199
      }
200

201 1
      elements.forEach(el => {
202 1
        const date = el.getAttribute('data-date')
203 1
        if (el.parentElement && date) {
204 1
          if (!(date in eventsMap)) {
205 1
            eventsMap[date] = {
206
              parent: el.parentElement,
207
              more: null,
208
              events: [],
209
            }
210
          }
211 1
          if (el.getAttribute('data-more')) {
212 1
            eventsMap[date].more = el
213
          } else {
214 1
            eventsMap[date].events.push(el)
215 1
            el.style.display = ''
216
          }
217
        }
218
      })
219

220 1
      return eventsMap
221
    },
222 0
    genDayEvent ({ event }: CalendarEventVisual, day: CalendarDaySlotScope): VNode {
223 0
      const eventHeight = this.eventHeight
224 0
      const eventMarginBottom = this.eventMarginBottom
225 0
      const dayIdentifier = getDayIdentifier(day)
226 0
      const week = day.week
227 0
      const start = dayIdentifier === event.startIdentifier
228 0
      let end = dayIdentifier === event.endIdentifier
229 0
      let width = WIDTH_START
230

231 1
      if (!this.categoryMode) {
232 0
        for (let i = day.index + 1; i < week.length; i++) {
233 0
          const weekdayIdentifier = getDayIdentifier(week[i])
234 1
          if (event.endIdentifier >= weekdayIdentifier) {
235 0
            width += WIDTH_FULL
236 1
            end = end || weekdayIdentifier === event.endIdentifier
237
          } else {
238 0
            end = true
239 0
            break
240
          }
241
        }
242
      }
243 0
      const scope = { eventParsed: event, day, start, end, timed: false }
244

245 0
      return this.genEvent(event, scope, false, {
246
        staticClass: 'v-event',
247
        class: {
248
          'v-event-start': start,
249
          'v-event-end': end,
250
        },
251
        style: {
252
          height: `${eventHeight}px`,
253
          width: `${width}%`,
254
          'margin-bottom': `${eventMarginBottom}px`,
255
        },
256
        attrs: {
257
          'data-date': day.date,
258
        },
259
        key: event.index,
260
        ref: 'events',
261
        refInFor: true,
262
      })
263
    },
264 0
    genTimedEvent ({ event, left, width }: CalendarEventVisual, day: CalendarDayBodySlotScope): VNode | false {
265 1
      if (day.timeDelta(event.end) <= 0 || day.timeDelta(event.start) >= 1) {
266 0
        return false
267
      }
268

269 0
      const dayIdentifier = getDayIdentifier(day)
270 0
      const start = event.startIdentifier >= dayIdentifier
271 0
      const end = event.endIdentifier > dayIdentifier
272 1
      const top = start ? day.timeToY(event.start) : 0
273 1
      const bottom = end ? day.timeToY(MINUTES_IN_DAY) : day.timeToY(event.end)
274 0
      const height = Math.max(this.eventHeight, bottom - top)
275 0
      const scope = { eventParsed: event, day, start, end, timed: true }
276

277 0
      return this.genEvent(event, scope, true, {
278
        staticClass: 'v-event-timed',
279
        style: {
280
          top: `${top}px`,
281
          height: `${height}px`,
282
          left: `${left}%`,
283
          width: `${width}%`,
284
        },
285
      })
286
    },
287 0
    genEvent (event: CalendarEventParsed, scopeInput: VEventScopeInput, timedEvent: boolean, data: VNodeData): VNode {
288 0
      const slot = this.$scopedSlots.event
289 0
      const text = this.eventTextColorFunction(event.input)
290 0
      const background = this.eventColorFunction(event.input)
291 1
      const overlapsNoon = event.start.hour < 12 && event.end.hour >= 12
292 0
      const singline = diffMinutes(event.start, event.end) <= this.parsedEventOverlapThreshold
293 0
      const formatTime = this.formatTime
294 0
      const timeSummary = () => formatTime(event.start, overlapsNoon) + ' - ' + formatTime(event.end, true)
295 0
      const eventSummary = () => {
296 0
        const name = this.eventNameFunction(event, timedEvent)
297

298 1
        if (event.start.hasTime) {
299 1
          if (timedEvent) {
300 0
            const time = timeSummary()
301 1
            const delimiter = singline ? ', ' : '<br>'
302

303 0
            return `<strong>${name}</strong>${delimiter}${time}`
304
          } else {
305 0
            const time = formatTime(event.start, true)
306

307 0
            return `<strong>${time}</strong> ${name}`
308
          }
309
        }
310

311 0
        return name
312
      }
313

314 0
      const scope = {
315
        ...scopeInput,
316
        event: event.input,
317
        outside: scopeInput.day.outside,
318
        singline,
319
        overlapsNoon,
320
        formatTime,
321
        timeSummary,
322
        eventSummary,
323
      }
324

325 0
      return this.$createElement('div',
326
        this.setTextColor(text,
327
          this.setBackgroundColor(background, {
328 0
            on: this.getDefaultMouseEventHandlers(':event', nativeEvent => ({ ...scope, nativeEvent })),
329
            directives: [{
330
              name: 'ripple',
331 1
              value: this.eventRipple ?? true,
332
            }],
333
            ...data,
334
          })
335 1
        ), slot
336 0
          ? slot(scope)
337 0
          : [this.genName(eventSummary)]
338
      )
339
    },
340 0
    genName (eventSummary: () => string): VNode {
341 0
      return this.$createElement('div', {
342
        staticClass: 'pl-1',
343
        domProps: {
344
          innerHTML: eventSummary(),
345
        },
346
      })
347
    },
348 0
    genPlaceholder (day: CalendarTimestamp): VNode {
349 0
      const height = this.eventHeight + this.eventMarginBottom
350

351 0
      return this.$createElement('div', {
352
        style: {
353
          height: `${height}px`,
354
        },
355
        attrs: {
356
          'data-date': day.date,
357
        },
358
        ref: 'events',
359
        refInFor: true,
360
      })
361
    },
362 0
    genMore (day: CalendarDaySlotScope): VNode {
363 0
      const eventHeight = this.eventHeight
364 0
      const eventMarginBottom = this.eventMarginBottom
365

366 0
      return this.$createElement('div', {
367
        staticClass: 'v-event-more pl-1',
368
        class: {
369
          'v-outside': day.outside,
370
        },
371
        attrs: {
372
          'data-date': day.date,
373
          'data-more': 1,
374
        },
375
        directives: [{
376
          name: 'ripple',
377 1
          value: this.eventRipple ?? true,
378
        }],
379
        on: {
380 0
          click: () => this.$emit('click:more', day),
381
        },
382
        style: {
383
          display: 'none',
384
          height: `${eventHeight}px`,
385
          'margin-bottom': `${eventMarginBottom}px`,
386
        },
387
        ref: 'events',
388
        refInFor: true,
389
      })
390
    },
391 0
    getVisibleEvents (): CalendarEventParsed[] {
392 0
      const start = getDayIdentifier(this.days[0])
393 0
      const end = getDayIdentifier(this.days[this.days.length - 1])
394

395 0
      return this.parsedEvents.filter(
396 0
        event => isEventOverlapping(event, start, end)
397
      )
398
    },
399 1
    isEventForCategory (event: CalendarEventParsed, category: string | undefined | null): boolean {
400 1
      return !this.categoryMode ||
401 0
        category === event.category ||
402 0
        (typeof event.category !== 'string' && category === null)
403
    },
404 1
    getEventsForDay (day: CalendarDaySlotScope): CalendarEventParsed[] {
405 1
      const identifier = getDayIdentifier(day)
406 1
      const firstWeekday = this.eventWeekdays[0]
407

408 1
      return this.parsedEvents.filter(
409 1
        event => isEventStart(event, day, identifier, firstWeekday)
410
      )
411
    },
412 1
    getEventsForDayAll (day: CalendarDaySlotScope): CalendarEventParsed[] {
413 1
      const identifier = getDayIdentifier(day)
414 1
      const firstWeekday = this.eventWeekdays[0]
415

416 1
      return this.parsedEvents.filter(
417 1
        event => event.allDay &&
418 1
          (this.categoryMode ? isEventOn(event, identifier) : isEventStart(event, day, identifier, firstWeekday)) &&
419 1
          this.isEventForCategory(event, day.category)
420
      )
421
    },
422 1
    getEventsForDayTimed (day: CalendarDaySlotScope): CalendarEventParsed[] {
423 1
      const identifier = getDayIdentifier(day)
424

425 1
      return this.parsedEvents.filter(
426 1
        event => !event.allDay &&
427 1
          isEventOn(event, identifier) &&
428 1
          this.isEventForCategory(event, day.category)
429
      )
430
    },
431 1
    getScopedSlots () {
432 1
      if (this.noEvents) {
433 1
        return { ...this.$scopedSlots }
434
      }
435

436 0
      const mode = this.eventModeFunction(
437
        this.parsedEvents,
438
        this.eventWeekdays[0],
439
        this.parsedEventOverlapThreshold
440
      )
441

442 0
      const isNode = (input: VNode | false): input is VNode => !!input
443 0
      const getSlotChildren: VEventsToNodes = (day, getter, mapper, timed) => {
444 0
        const events = getter(day)
445 0
        const visuals = mode(day, events, timed, this.categoryMode)
446

447 1
        if (timed) {
448 0
          return visuals.map(visual => mapper(visual, day)).filter(isNode)
449
        }
450

451 0
        const children: VNode[] = []
452

453 0
        visuals.forEach((visual, index) => {
454 0
          while (children.length < visual.column) {
455 0
            children.push(this.genPlaceholder(day))
456
          }
457

458 0
          const mapped = mapper(visual, day)
459 1
          if (mapped) {
460 0
            children.push(mapped)
461
          }
462
        })
463

464 0
        return children
465
      }
466

467 0
      const slots = this.$scopedSlots
468 0
      const slotDay = slots.day
469 0
      const slotDayHeader = slots['day-header']
470 0
      const slotDayBody = slots['day-body']
471

472 0
      return {
473
        ...slots,
474 0
        day: (day: CalendarDaySlotScope) => {
475 0
          let children = getSlotChildren(day, this.getEventsForDay, this.genDayEvent, false)
476 1
          if (children && children.length > 0 && this.eventMore) {
477 0
            children.push(this.genMore(day))
478
          }
479 1
          if (slotDay) {
480 0
            const slot = slotDay(day)
481 1
            if (slot) {
482 1
              children = children ? children.concat(slot) : slot
483
            }
484
          }
485 0
          return children
486
        },
487 0
        'day-header': (day: CalendarDaySlotScope) => {
488 0
          let children = getSlotChildren(day, this.getEventsForDayAll, this.genDayEvent, false)
489

490 1
          if (slotDayHeader) {
491 0
            const slot = slotDayHeader(day)
492 1
            if (slot) {
493 1
              children = children ? children.concat(slot) : slot
494
            }
495
          }
496 0
          return children
497
        },
498 0
        'day-body': (day: CalendarDayBodySlotScope) => {
499 0
          const events = getSlotChildren(day, this.getEventsForDayTimed, this.genTimedEvent, true)
500 0
          let children: VNode[] = [
501
            this.$createElement('div', {
502
              staticClass: 'v-event-timed-container',
503
            }, events),
504
          ]
505

506 1
          if (slotDayBody) {
507 0
            const slot = slotDayBody(day)
508 1
            if (slot) {
509 0
              children = children.concat(slot)
510
            }
511
          }
512 0
          return children
513
        },
514
      }
515
    },
516
  },
517
})

Read our documentation on viewing source code .

Loading