UNPKG

vuetify

Version:

Vue Material Component Framework

518 lines (465 loc) 15.3 kB
// Styles import './calendar-with-events.sass' // Types import { VNode, VNodeData } from 'vue' // Directives import ripple from '../../../directives/ripple' // Mixins import CalendarBase from './calendar-base' // Helpers import { escapeHTML } from '../../../util/helpers' // Util import props from '../util/props' import { CalendarEventOverlapModes, } from '../modes' import { getDayIdentifier, diffMinutes, } from '../util/timestamp' import { parseEvent, isEventStart, isEventOn, isEventOverlapping, } from '../util/events' import { CalendarTimestamp, CalendarEventParsed, CalendarEventVisual, CalendarEventColorFunction, CalendarEventNameFunction, CalendarEventTimedFunction, CalendarDaySlotScope, CalendarDayBodySlotScope, CalendarEventOverlapMode, CalendarEvent, CalendarEventCategoryFunction, } from 'vuetify/types' // Types type VEventGetter<D> = (day: D) => CalendarEventParsed[] type VEventVisualToNode<D> = (visual: CalendarEventVisual, day: D) => VNode | false type VEventsToNodes = <D extends CalendarDaySlotScope>( day: D, getter: VEventGetter<D>, mapper: VEventVisualToNode<D>, timed: boolean) => VNode[] | undefined type VDailyEventsMap = { [date: string]: { parent: HTMLElement more: HTMLElement | null events: HTMLElement[] } } interface VEventScopeInput { eventParsed: CalendarEventParsed day: CalendarDaySlotScope start: boolean end: boolean timed: boolean } const WIDTH_FULL = 100 const WIDTH_START = 95 const MINUTES_IN_DAY = 1440 /* @vue/component */ export default CalendarBase.extend({ name: 'calendar-with-events', directives: { ripple, }, props: props.events, computed: { noEvents (): boolean { return this.events.length === 0 }, parsedEvents (): CalendarEventParsed[] { return this.events.map(this.parseEvent) }, parsedEventOverlapThreshold (): number { return parseInt(this.eventOverlapThreshold) }, eventColorFunction (): CalendarEventColorFunction { return typeof this.eventColor === 'function' ? this.eventColor : () => (this.eventColor as string) }, eventTimedFunction (): CalendarEventTimedFunction { return typeof this.eventTimed === 'function' ? this.eventTimed : event => !!event[this.eventTimed as string] }, eventCategoryFunction (): CalendarEventCategoryFunction { return typeof this.eventCategory === 'function' ? this.eventCategory : event => event[this.eventCategory as string] }, eventTextColorFunction (): CalendarEventColorFunction { return typeof this.eventTextColor === 'function' ? this.eventTextColor : () => this.eventTextColor as string }, eventNameFunction (): CalendarEventNameFunction { return typeof this.eventName === 'function' ? this.eventName : (event, timedEvent) => escapeHTML(event.input[this.eventName as string] as string) }, eventModeFunction (): CalendarEventOverlapMode { return typeof this.eventOverlapMode === 'function' ? this.eventOverlapMode : CalendarEventOverlapModes[this.eventOverlapMode] }, eventWeekdays (): number[] { return this.parsedWeekdays }, categoryMode (): boolean { return false }, }, methods: { parseEvent (input: CalendarEvent, index = 0): CalendarEventParsed { return parseEvent( input, index, this.eventStart, this.eventEnd, this.eventTimedFunction(input), this.categoryMode ? this.eventCategoryFunction(input) : false, ) }, formatTime (withTime: CalendarTimestamp, ampm: boolean): string { const formatter = this.getFormatter({ timeZone: 'UTC', hour: 'numeric', minute: withTime.minute > 0 ? 'numeric' : undefined, }) return formatter(withTime, true) }, updateEventVisibility () { if (this.noEvents || !this.eventMore) { return } const eventHeight = this.eventHeight const eventsMap = this.getEventsMap() for (const date in eventsMap) { const { parent, events, more } = eventsMap[date] if (!more) { break } const parentBounds = parent.getBoundingClientRect() const last = events.length - 1 let hide = false let hidden = 0 for (let i = 0; i <= last; i++) { if (!hide) { const eventBounds = events[i].getBoundingClientRect() hide = i === last ? (eventBounds.bottom > parentBounds.bottom) : (eventBounds.bottom + eventHeight > parentBounds.bottom) } if (hide) { events[i].style.display = 'none' hidden++ } } if (hide) { more.style.display = '' more.innerHTML = this.$vuetify.lang.t(this.eventMoreText, hidden) } else { more.style.display = 'none' } } }, getEventsMap (): VDailyEventsMap { const eventsMap: VDailyEventsMap = {} const elements = this.$refs.events as HTMLElement[] if (!elements || !elements.forEach) { return eventsMap } elements.forEach(el => { const date = el.getAttribute('data-date') if (el.parentElement && date) { if (!(date in eventsMap)) { eventsMap[date] = { parent: el.parentElement, more: null, events: [], } } if (el.getAttribute('data-more')) { eventsMap[date].more = el } else { eventsMap[date].events.push(el) el.style.display = '' } } }) return eventsMap }, genDayEvent ({ event }: CalendarEventVisual, day: CalendarDaySlotScope): VNode { const eventHeight = this.eventHeight const eventMarginBottom = this.eventMarginBottom const dayIdentifier = getDayIdentifier(day) const week = day.week const start = dayIdentifier === event.startIdentifier let end = dayIdentifier === event.endIdentifier let width = WIDTH_START if (!this.categoryMode) { for (let i = day.index + 1; i < week.length; i++) { const weekdayIdentifier = getDayIdentifier(week[i]) if (event.endIdentifier >= weekdayIdentifier) { width += WIDTH_FULL end = end || weekdayIdentifier === event.endIdentifier } else { end = true break } } } const scope = { eventParsed: event, day, start, end, timed: false } return this.genEvent(event, scope, false, { staticClass: 'v-event', class: { 'v-event-start': start, 'v-event-end': end, }, style: { height: `${eventHeight}px`, width: `${width}%`, 'margin-bottom': `${eventMarginBottom}px`, }, attrs: { 'data-date': day.date, }, key: event.index, ref: 'events', refInFor: true, }) }, genTimedEvent ({ event, left, width }: CalendarEventVisual, day: CalendarDayBodySlotScope): VNode | false { if (day.timeDelta(event.end) <= 0 || day.timeDelta(event.start) >= 1) { return false } const dayIdentifier = getDayIdentifier(day) const start = event.startIdentifier >= dayIdentifier const end = event.endIdentifier > dayIdentifier const top = start ? day.timeToY(event.start) : 0 const bottom = end ? day.timeToY(MINUTES_IN_DAY) : day.timeToY(event.end) const height = Math.max(this.eventHeight, bottom - top) const scope = { eventParsed: event, day, start, end, timed: true } return this.genEvent(event, scope, true, { staticClass: 'v-event-timed', style: { top: `${top}px`, height: `${height}px`, left: `${left}%`, width: `${width}%`, }, }) }, genEvent (event: CalendarEventParsed, scopeInput: VEventScopeInput, timedEvent: boolean, data: VNodeData): VNode { const slot = this.$scopedSlots.event const text = this.eventTextColorFunction(event.input) const background = this.eventColorFunction(event.input) const overlapsNoon = event.start.hour < 12 && event.end.hour >= 12 const singline = diffMinutes(event.start, event.end) <= this.parsedEventOverlapThreshold const formatTime = this.formatTime const timeSummary = () => formatTime(event.start, overlapsNoon) + ' - ' + formatTime(event.end, true) const eventSummary = () => { const name = this.eventNameFunction(event, timedEvent) if (event.start.hasTime) { if (timedEvent) { const time = timeSummary() const delimiter = singline ? ', ' : '<br>' return `<strong>${name}</strong>${delimiter}${time}` } else { const time = formatTime(event.start, true) return `<strong>${time}</strong> ${name}` } } return name } const scope = { ...scopeInput, event: event.input, outside: scopeInput.day.outside, singline, overlapsNoon, formatTime, timeSummary, eventSummary, } return this.$createElement('div', this.setTextColor(text, this.setBackgroundColor(background, { on: this.getDefaultMouseEventHandlers(':event', nativeEvent => ({ ...scope, nativeEvent })), directives: [{ name: 'ripple', value: this.eventRipple ?? true, }], ...data, }) ), slot ? slot(scope) : [this.genName(eventSummary)] ) }, genName (eventSummary: () => string): VNode { return this.$createElement('div', { staticClass: 'pl-1', domProps: { innerHTML: eventSummary(), }, }) }, genPlaceholder (day: CalendarTimestamp): VNode { const height = this.eventHeight + this.eventMarginBottom return this.$createElement('div', { style: { height: `${height}px`, }, attrs: { 'data-date': day.date, }, ref: 'events', refInFor: true, }) }, genMore (day: CalendarDaySlotScope): VNode { const eventHeight = this.eventHeight const eventMarginBottom = this.eventMarginBottom return this.$createElement('div', { staticClass: 'v-event-more pl-1', class: { 'v-outside': day.outside, }, attrs: { 'data-date': day.date, 'data-more': 1, }, directives: [{ name: 'ripple', value: this.eventRipple ?? true, }], on: { click: () => this.$emit('click:more', day), }, style: { display: 'none', height: `${eventHeight}px`, 'margin-bottom': `${eventMarginBottom}px`, }, ref: 'events', refInFor: true, }) }, getVisibleEvents (): CalendarEventParsed[] { const start = getDayIdentifier(this.days[0]) const end = getDayIdentifier(this.days[this.days.length - 1]) return this.parsedEvents.filter( event => isEventOverlapping(event, start, end) ) }, isEventForCategory (event: CalendarEventParsed, category: string | undefined | null): boolean { return !this.categoryMode || category === event.category || (typeof event.category !== 'string' && category === null) }, getEventsForDay (day: CalendarDaySlotScope): CalendarEventParsed[] { const identifier = getDayIdentifier(day) const firstWeekday = this.eventWeekdays[0] return this.parsedEvents.filter( event => isEventStart(event, day, identifier, firstWeekday) ) }, getEventsForDayAll (day: CalendarDaySlotScope): CalendarEventParsed[] { const identifier = getDayIdentifier(day) const firstWeekday = this.eventWeekdays[0] return this.parsedEvents.filter( event => event.allDay && (this.categoryMode ? isEventOn(event, identifier) : isEventStart(event, day, identifier, firstWeekday)) && this.isEventForCategory(event, day.category) ) }, getEventsForDayTimed (day: CalendarDaySlotScope): CalendarEventParsed[] { const identifier = getDayIdentifier(day) return this.parsedEvents.filter( event => !event.allDay && isEventOn(event, identifier) && this.isEventForCategory(event, day.category) ) }, getScopedSlots () { if (this.noEvents) { return { ...this.$scopedSlots } } const mode = this.eventModeFunction( this.parsedEvents, this.eventWeekdays[0], this.parsedEventOverlapThreshold ) const isNode = (input: VNode | false): input is VNode => !!input const getSlotChildren: VEventsToNodes = (day, getter, mapper, timed) => { const events = getter(day) const visuals = mode(day, events, timed, this.categoryMode) if (timed) { return visuals.map(visual => mapper(visual, day)).filter(isNode) } const children: VNode[] = [] visuals.forEach((visual, index) => { while (children.length < visual.column) { children.push(this.genPlaceholder(day)) } const mapped = mapper(visual, day) if (mapped) { children.push(mapped) } }) return children } const slots = this.$scopedSlots const slotDay = slots.day const slotDayHeader = slots['day-header'] const slotDayBody = slots['day-body'] return { ...slots, day: (day: CalendarDaySlotScope) => { let children = getSlotChildren(day, this.getEventsForDay, this.genDayEvent, false) if (children && children.length > 0 && this.eventMore) { children.push(this.genMore(day)) } if (slotDay) { const slot = slotDay(day) if (slot) { children = children ? children.concat(slot) : slot } } return children }, 'day-header': (day: CalendarDaySlotScope) => { let children = getSlotChildren(day, this.getEventsForDayAll, this.genDayEvent, false) if (slotDayHeader) { const slot = slotDayHeader(day) if (slot) { children = children ? children.concat(slot) : slot } } return children }, 'day-body': (day: CalendarDayBodySlotScope) => { const events = getSlotChildren(day, this.getEventsForDayTimed, this.genTimedEvent, true) let children: VNode[] = [ this.$createElement('div', { staticClass: 'v-event-timed-container', }, events), ] if (slotDayBody) { const slot = slotDayBody(day) if (slot) { children = children.concat(slot) } } return children }, } }, }, })