UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

723 lines (663 loc) 23.2 kB
import { classMap } from 'lit/directives/class-map.js' import { customElement, property, state } from 'lit/decorators.js' import { formatISODate, newDate, newDateYMD, formatReadableDate, parseISODateString, todayInTz, } from '@/utils/dateutils' import { getWeek, eachDayOfInterval, getISODay, addDays } from 'date-fns' import { html, nothing, PropertyValues } from 'lit' import { PktElement } from '@/base-elements/element' import converters from '../../helpers/converters' import specs from 'componentSpecs/calendar.json' import '@/components/icon' type DatesInRange = { [key: string]: boolean } @customElement('pkt-calendar') export class PktCalendar extends PktElement { /** * Element attributes */ @property({ type: Boolean }) multiple: boolean = specs.props.multiple.default @property({ type: Number }) maxMultiple: number = specs.props.maxMultiple.default @property({ type: Boolean }) range: boolean = specs.props.range.default @property({ type: Boolean }) weeknumbers: boolean = specs.props.weeknumbers.default @property({ type: Boolean }) withcontrols: boolean = specs.props.withcontrols.default @property({ converter: converters.csvToArray }) selected: string | string[] = [] @property({ type: String }) earliest: string | null = specs.props.earliest.default @property({ type: String }) latest: string | null = specs.props.latest.default @property({ converter: converters.stringsToDate }) excludedates: Date[] = [] @property({ converter: converters.csvToArray }) excludeweekdays: string[] = [] @property({ converter: converters.stringToDate }) currentmonth: Date | null = null /** * Strings */ @property({ type: Array }) dayStrings: string[] = this.strings.dates.daysShort @property({ type: Array }) dayStringsLong: string[] = this.strings.dates.days @property({ type: Array }) monthStrings: string[] = this.strings.dates.months @property({ type: String }) weekString: string = this.strings.dates.week @property({ type: String }) prevMonthString: string = this.strings.dates.prevMonth @property({ type: String }) nextMonthString: string = this.strings.dates.nextMonth /** * Private properties */ @property({ type: Array }) private _selected: Date[] = [] @property({ type: Number }) private year: number = 0 @property({ type: Number }) private month: number = 0 @property({ type: Number }) private week: number = 0 @property({ type: Date }) private rangeHovered: Date | null = null @state() private inRange: DatesInRange = {} @state() private focusedDate: string | null = null @state() private selectableDates: { currentDateISO: string isDisabled: boolean tabindex: string }[] = [] @state() private currentmonthtouched: boolean = false @state() private tabIndexSet: number = 0 /** * Runs on mount, used to set up various values and whatever you need to run */ connectedCallback() { super.connectedCallback() } disconnectedCallback(): void { this.removeEventListener('keydown', this.handleKeydown) super.disconnectedCallback() } attributeChangedCallback(name: string, _old: string | null, value: string | null): void { if (name === 'selected' && value) { this.convertSelected() } super.attributeChangedCallback(name, _old, value) } updated(changedProperties: PropertyValues): void { super.updated(changedProperties) if (changedProperties.has('selected')) { this.convertSelected() } } protected firstUpdated(_changedProperties: PropertyValues): void { this.addEventListener('keydown', this.handleKeydown) } convertSelected() { if (typeof this.selected === 'string') { this.selected = this.selected.split(',') } if (this.selected.length === 1 && this.selected[0] === '') { this.selected = [] } this._selected = this.selected.map((d: string) => parseISODateString(d)) if (this.range && this.selected.length === 2) { const days = eachDayOfInterval({ start: this._selected[0], end: this._selected[1], }) this.inRange = {} if (Array.isArray(days) && days.length) { const inRange: DatesInRange = {} for (let i = 0; i < days.length; i++) { inRange[formatISODate(days[i])] = this.isInRange(days[i]) } this.inRange = inRange } } this.setCurrentMonth() } setCurrentMonth() { if (this.currentmonth === null && !this.currentmonthtouched) { this.currentmonthtouched = true return } if (this.selected.length && this.selected[0] !== '') { const d = parseISODateString(this.selected[this.selected.length - 1]) this.currentmonth = isNaN(d.getTime()) ? new Date() : d } else if (this.currentmonth === null) { this.currentmonth = new Date() } // fallback to today if invalid if (!this.currentmonth || isNaN(this.currentmonth.getTime())) { this.currentmonth = new Date() } this.year = this.currentmonth.getFullYear() this.month = this.currentmonth.getMonth() } handleKeydown(e: KeyboardEvent) { switch (e.key) { case 'ArrowLeft': this.handleArrowKey(e, -1) break case 'ArrowRight': this.handleArrowKey(e, 1) break case 'ArrowUp': this.handleArrowKey(e, -7) break case 'ArrowDown': this.handleArrowKey(e, 7) break default: break } } handleArrowKey(e: KeyboardEvent, direction: number) { if ((e.target as HTMLElement)?.nodeName === 'INPUT') return if ((e.target as HTMLElement)?.nodeName === 'SELECT') return if ((e.target as HTMLElement)?.nodeName === 'BUTTON') return e.preventDefault() if (!this.focusedDate) { this.focusOnCurrentDate() } const date = this.focusedDate ? newDate(this.focusedDate) : newDateYMD(this.year, this.month, 1) let nextDate = addDays(date, direction) if (nextDate) { let el = this.querySelector(`div[data-date="${formatISODate(nextDate)}"]`) if (el instanceof HTMLDivElement) { if (el.dataset.disabled) { nextDate = addDays(nextDate, direction) let nextElement = this.querySelector(`div[data-date="${formatISODate(nextDate)}"]`) while ( nextElement && nextElement instanceof HTMLDivElement && nextElement.dataset.disabled ) { nextDate = addDays(nextDate, direction) nextElement = this.querySelector(`div[data-date="${formatISODate(nextDate)}"]`) } el = nextElement } if (el instanceof HTMLDivElement && !el.dataset.disabled) { this.focusedDate = formatISODate(nextDate) el.focus() } } } } /** * Component functionality and render */ render() { return html` <div class="pkt-calendar ${this.weeknumbers ? 'pkt-cal-weeknumbers' : nothing}" @focusout=${this.closeEvent} @keydown=${(e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault() this.close() } }} > <nav class="pkt-cal-month-nav"> <div> <button type="button" @click=${this.isPrevMonthAllowed() && this.prevMonth} @keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() this.isNextMonthAllowed() && this.prevMonth() } }} class="pkt-btn pkt-btn--tertiary pkt-btn--small pkt-btn--icon-only ${this.isPrevMonthAllowed() ? '' : 'pkt-hide'}" .data-disabled=${!this.isPrevMonthAllowed() ? 'disabled' : nothing} ?aria-disabled=${!this.isPrevMonthAllowed()} tabindex=${this.isPrevMonthAllowed() ? '0' : '-1'} > <pkt-icon class="pkt-btn__icon" name="chevron-thin-left"></pkt-icon> <span class="pkt-btn__text">${this.prevMonthString}</span> </button> </div> ${this.renderMonthNav()} <div> <button type="button" @click=${this.isNextMonthAllowed() && this.nextMonth} @keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() this.isNextMonthAllowed() && this.nextMonth() } }} class="pkt-btn pkt-btn--tertiary pkt-btn--small pkt-btn--icon-only ${this.isNextMonthAllowed() ? '' : 'pkt-hide'}" .data-disabled=${!this.isNextMonthAllowed() ? 'disabled' : nothing} ?aria-disabled=${!this.isNextMonthAllowed()} tabindex=${this.isNextMonthAllowed() ? '0' : '-1'} > <pkt-icon class="pkt-btn__icon" name="chevron-thin-right"></pkt-icon> <span class="pkt-btn__text">${this.nextMonthString}</span> </button> </div> </nav> <table class="pkt-cal-days pkt-txt-12-medium" role="grid" ?aria-multiselectable=${this.range || this.multiple} > <thead> ${this.renderDayNames()} </thead> <tbody> ${this.renderCalendarBody()} </tbody> </table> </div> ` } private renderDayNames() { const days = [] if (this.weeknumbers) { days.push(html`<th><div>${this.weekString}</div></th>`) } for (let i = 0; i < this.dayStrings.length; i++) { days.push( html`<th><div aria-label="${this.dayStringsLong[i]}">${this.dayStrings[i]}</div></th>`, ) } return html`<tr class="pkt-cal-week-row"> ${days} </tr>` } private renderMonthNav() { let monthView = [] if (this.withcontrols) { monthView.push( html`<div class="pkt-cal-month-picker"> <label for="${this.id}-monthnav" class="pkt-hide">${this.strings.dates.month}</label> <select aria-label="${this.strings.dates.month}" class="pkt-input pkt-input-compact" id="${this.id}-monthnav" @change=${(e: any) => { e.stopImmediatePropagation() this.changeMonth(this.year, e.target.value) }} > ${this.monthStrings.map( (month, index) => html`<option value=${index} ?selected=${this.month === index}>${month}</option>`, )} </select> <label for="${this.id}-yearnav" class="pkt-hide">${this.strings.dates.year}</label> <input aria-label="${this.strings.dates.year}" class="pkt-input pkt-cal-input-year pkt-input-compact" id="${this.id}-yearnav" type="number" size="4" placeholder="0000" @change=${(e: any) => { e.stopImmediatePropagation() this.changeMonth(e.target.value, this.month) }} .value=${this.year} /> </div> `, ) } else { monthView.push( html`<div class="pkt-txt-16-medium" aria-live="polite"> ${this.monthStrings[this.month]} ${this.year} </div>`, ) } return monthView } private renderDayView(dayCounter: number, today: Date, j: number) { const currentDate = newDateYMD(this.year, this.month, dayCounter) const currentDateISO = formatISODate(currentDate) const isToday = currentDateISO === formatISODate(today) const isSelected = this.selected.includes(currentDateISO) const ariaLabel = formatReadableDate(currentDate) const isDisabled = this.isExcluded(j, currentDate) || (!isSelected && this.multiple && this.maxMultiple > 0 && this.selected.length >= this.maxMultiple) const tabindex = this.focusedDate ? this.focusedDate === currentDateISO && !isDisabled ? '0' : '-1' : !isDisabled && this.tabIndexSet === 0 ? '0' : this.tabIndexSet === dayCounter ? '0' : '-1' if (tabindex === '0') { this.tabIndexSet = dayCounter } this.selectableDates.push({ currentDateISO, isDisabled, tabindex }) const classes = { 'pkt-cal-today': isToday, 'pkt-cal-selected': isSelected, 'pkt-cal-in-range': this.inRange[currentDateISO], 'pkt-cal-excluded': this.isExcluded(j, currentDate), 'pkt-cal-in-range-first': this.range && (this.selected.length === 2 || this.rangeHovered !== null) && currentDateISO === this.selected[0], 'pkt-cal-in-range-last': this.range && this.selected.length === 2 && currentDateISO === this.selected[1], 'pkt-cal-range-hover': this.rangeHovered !== null && currentDateISO === formatISODate(this.rangeHovered), } return html`<td class=${classMap(classes)}> <div ?aria-selected=${isSelected} role="gridcell" class="pkt-btn pkt-btn--tertiary pkt-btn--small pkt-btn--label-only" @mouseover=${() => this.range && !this.isExcluded(j, currentDate) && this.handleRangeHover(currentDate)} @focus=${() => { this.range && !this.isExcluded(j, currentDate) && this.handleRangeHover(currentDate) this.focusedDate = currentDateISO }} aria-label="${ariaLabel}" tabindex=${this.selectableDates.find((x) => x.currentDateISO === currentDateISO)?.tabindex} data-disabled=${isDisabled ? 'disabled' : nothing} data-date=${currentDateISO} @keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() this.handleDateSelect(currentDate) } }} @click=${(e: MouseEvent) => { if (!isDisabled) { e.preventDefault() this.handleDateSelect(currentDate) } }} > <span class="pkt-btn__text pkt-txt-14-light">${dayCounter}</span> </div> </td>` } private renderCalendarBody() { const today = todayInTz() const firstDayOfMonth = newDateYMD(this.year, this.month, 1) const lastDayOfMonth = newDateYMD(this.year, this.month + 1, 0) const startingDay = (firstDayOfMonth.getDay() + 6) % 7 const numDays = lastDayOfMonth.getDate() const numRows = Math.ceil((numDays + startingDay) / 7) const lastDayOfPrevMonth = newDateYMD(this.year, this.month, 0) const numDaysPrevMonth = lastDayOfPrevMonth.getDate() let dayCounter = 1 this.week = getWeek(newDateYMD(this.year, this.month, 1)) const rows = [] for (let i = 0; i < numRows; i++) { const cells = [] this.weeknumbers && cells.push(html`<td class="pkt-cal-week">${this.week}</td>`) this.week++ for (let j = 1; j < 8; j++) { if (i === 0 && j < startingDay + 1) { const dayFromPrevMonth = numDaysPrevMonth - (startingDay - j) cells.push( html`<td class="pkt-cal-other"> <div class="pkt-btn pkt-btn--tertiary pkt-btn--small pkt-btn--label-only" data-disabled="disabled" > <span class="pkt-btn__text pkt-txt-14-light">${dayFromPrevMonth}</span> </div> </td>`, ) } else if (dayCounter > numDays) { cells.push( html`<td class="pkt-cal-other"> <div class="pkt-btn pkt-btn--tertiary pkt-btn--small pkt-btn--label-only" data-disabled="disabled" > <span class="pkt-btn__text pkt-txt-14-light">${dayCounter - numDays}</span> </div> </td>`, ) dayCounter++ } else { cells.push(this.renderDayView(dayCounter, today, j)) dayCounter++ } } rows.push( html`<tr class="pkt-cal-week-row" role="row"> ${cells} </tr>`, ) } return rows } private isExcluded(weekday: number, date: Date) { if (this.excludeweekdays.includes(weekday.toString())) return true if (this.earliest && newDate(date, 'end') < newDate(this.earliest, 'start')) return true if (this.latest && newDate(date, 'start') > newDate(this.latest, 'end')) return true return this.excludedates.some((x: Date | string) => { if (typeof x === 'string') { return x === formatISODate(date) } else { return x.toDateString() === date.toDateString() } }) } isPrevMonthAllowed() { const prevMonth = newDateYMD(this.year, this.month, 0) if (this.earliest && newDate(this.earliest) > prevMonth) return false return true } private prevMonth() { const newMonth = this.month === 0 ? 11 : this.month - 1 const newYear = this.month === 0 ? this.year - 1 : this.year this.changeMonth(newYear, newMonth) } isNextMonthAllowed() { const nextMonth = newDateYMD( this.month === 11 ? this.year + 1 : this.year, this.month === 11 ? 0 : this.month + 1, 1, ) if (this.latest && newDate(this.latest) < nextMonth) return false return true } private nextMonth() { const newMonth = this.month === 11 ? 0 : this.month + 1 const newYear = this.month === 11 ? this.year + 1 : this.year this.changeMonth(newYear, newMonth) } private changeMonth(year: number, month: number) { this.year = typeof year === 'string' ? parseInt(year) : year this.month = typeof month === 'string' ? parseInt(month) : month this.tabIndexSet = 0 this.focusedDate = null this.selectableDates = [] } private isInRange(date: Date) { if (this.range && this.selected.length === 2) { if (date > newDate(this.selected[0]) && date < newDate(this.selected[1])) return true } else if (this.range && this.selected.length === 1 && this.rangeHovered) { if (date > newDate(this.selected[0]) && date < this.rangeHovered) return true } return false } private isRangeAllowed(date: Date) { let allowed = true if (this._selected.length === 1) { const days = eachDayOfInterval({ start: this._selected[0], end: date, }) if (Array.isArray(days) && days.length) { for (let i = 0; i < days.length; i++) { this.excludedates.forEach((d: Date) => { if (d > this._selected[0] && d < date) { allowed = false } }) if (this.excludeweekdays.includes(getISODay(days[i]).toString())) { allowed = false } } } } return allowed } private emptySelected() { this.selected = [] this._selected = [] this.inRange = {} } public addToSelected(selectedDate: Date) { if (this.selected.includes(formatISODate(selectedDate))) return this.selected = [...this.selected, formatISODate(selectedDate)] this._selected = [...this._selected, selectedDate] if (this.range && this.selected.length === 2) { this.close() } } public removeFromSelected(selectedDate: Date) { if (this.selected.length === 1) { this.emptySelected() } else { const selectedDateIndex = this.selected.indexOf(formatISODate(selectedDate)) const selectedCopy = [...this.selected] const _selectedCopy = [...this._selected] selectedCopy.splice(selectedDateIndex, 1) _selectedCopy.splice(selectedDateIndex, 1) this.selected = selectedCopy this._selected = _selectedCopy } } public toggleSelected(selectedDate: Date) { const selectedDateISO = formatISODate(selectedDate) this.selected.includes(selectedDateISO) ? this.removeFromSelected(selectedDate) : !(this.maxMultiple && this.selected.length >= this.maxMultiple) ? this.addToSelected(selectedDate) : null } private handleRangeSelect(selectedDate: Date) { const selectedDateISO = formatISODate(selectedDate) if (this.selected.includes(selectedDateISO)) { if (this.selected.indexOf(selectedDateISO) === 0) { this.emptySelected() } else { this.removeFromSelected(selectedDate) } } else { if (this.selected.length > 1) { this.emptySelected() this.addToSelected(selectedDate) } else { if (this.selected.length === 1 && !this.isRangeAllowed(selectedDate)) { this.emptySelected() } if (this.selected.length === 1 && this._selected[0] > selectedDate) { this.emptySelected() } this.addToSelected(selectedDate) } } return Promise.resolve() } private handleRangeHover(date: Date) { if ( this.range && this._selected.length === 1 && this.isRangeAllowed(date) && this._selected[0] < date ) { this.rangeHovered = date this.inRange = {} const days = eachDayOfInterval({ start: this._selected[0], end: date, }) if (Array.isArray(days) && days.length) { for (let i = 0; i < days.length; i++) { this.inRange[formatISODate(days[i])] = this.isInRange(days[i]) } } } else { this.rangeHovered = null } } public handleDateSelect(selectedDate: Date | null) { if (!selectedDate) return if (this.range) { this.handleRangeSelect(selectedDate) } else if (this.multiple) { this.toggleSelected(selectedDate) } else { if (this.selected.includes(formatISODate(selectedDate))) { this.emptySelected() } else { this.emptySelected() this.addToSelected(selectedDate) } this.close() } this.dispatchEvent( new CustomEvent('date-selected', { detail: this.selected, bubbles: true, composed: true, }), ) return Promise.resolve() } public focusOnCurrentDate() { const currentDateISO = formatISODate(newDate()) const el = this.querySelector(`div[data-date="${currentDateISO}"]`) if (el instanceof HTMLDivElement) { this.focusedDate = currentDateISO el.focus() } else { const firstSelectable = this.selectableDates.find((x) => !x.isDisabled) if (firstSelectable) { const firstSelectableEl = this.querySelector( `div[data-date="${firstSelectable.currentDateISO}"]`, ) if (firstSelectableEl instanceof HTMLDivElement) { this.focusedDate = firstSelectable.currentDateISO firstSelectableEl.focus() } } } } public closeEvent(e: FocusEvent) { if ( !this.contains(e.relatedTarget as Node) && !(e.target as Element).classList.contains('pkt-hide') ) { this.close() } } public close() { this.dispatchEvent( new CustomEvent('close', { detail: true, bubbles: true, composed: true, }), ) } }