@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
723 lines (663 loc) • 23.2 kB
text/typescript
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
}
export class PktCalendar extends PktElement {
/**
* Element attributes
*/
multiple: boolean = specs.props.multiple.default
maxMultiple: number = specs.props.maxMultiple.default
range: boolean = specs.props.range.default
weeknumbers: boolean = specs.props.weeknumbers.default
withcontrols: boolean = specs.props.withcontrols.default
selected: string | string[] = []
earliest: string | null = specs.props.earliest.default
latest: string | null = specs.props.latest.default
excludedates: Date[] = []
excludeweekdays: string[] = []
currentmonth: Date | null = null
/**
* Strings
*/
dayStrings: string[] = this.strings.dates.daysShort
dayStringsLong: string[] = this.strings.dates.days
monthStrings: string[] = this.strings.dates.months
weekString: string = this.strings.dates.week
prevMonthString: string = this.strings.dates.prevMonth
nextMonthString: string = this.strings.dates.nextMonth
/**
* Private properties
*/
private _selected: Date[] = []
private year: number = 0
private month: number = 0
private week: number = 0
private rangeHovered: Date | null = null
private inRange: DatesInRange = {}
private focusedDate: string | null = null
private selectableDates: {
currentDateISO: string
isDisabled: boolean
tabindex: string
}[] = []
private currentmonthtouched: boolean = false
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}"
=${this.closeEvent}
=${(e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
this.close()
}
}}
>
<nav class="pkt-cal-month-nav">
<div>
<button
type="button"
=${this.isPrevMonthAllowed() && this.prevMonth}
=${(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"
=${this.isNextMonthAllowed() && this.nextMonth}
=${(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"
=${(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"
=${(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"
=${() =>
this.range && !this.isExcluded(j, currentDate) && this.handleRangeHover(currentDate)}
=${() => {
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}
=${(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
this.handleDateSelect(currentDate)
}
}}
=${(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,
}),
)
}
}