@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
726 lines (631 loc) • 22.8 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,
newDateFromDate,
} from 'shared-utils/date-utils'
import { html, nothing, PropertyValues, TemplateResult } from 'lit'
import { PktElement } from '@/base-elements/element'
import converters from '../../helpers/converters'
import specs from 'componentSpecs/calendar.json'
import '@/components/icon'
import type { IDateConstraints, TDateRangeMap } from 'shared-types/calendar'
import {
isDateExcluded,
isDayDisabled as checkDayDisabled,
isPrevMonthAllowed as checkPrevMonthAllowed,
isNextMonthAllowed as checkNextMonthAllowed,
} from 'shared-utils/calendar/date-validation'
import {
DAYS_PER_WEEK,
calculateCalendarDimensions,
getCellType,
getDayNumber,
} from 'shared-utils/calendar/calendar-grid'
import {
convertSelectedToDates,
updateRangeMap as calculateRangeMap,
isRangeAllowed as checkRangeAllowed,
addToSelection,
removeFromSelection,
toggleSelection,
handleRangeSelection,
} from 'shared-utils/calendar/selection-manager'
import {
shouldIgnoreKeyboardEvent,
findNextSelectableDate as findNextDate,
getKeyDirection,
} from 'shared-utils/calendar/keyboard-navigation'
// Types
type TDayViewData = {
currentDate: Date
currentDateISO: string
isToday: boolean
isSelected: boolean
isDisabled: boolean
ariaLabel: string
tabindex: string
}
export class PktCalendar extends PktElement {
// Selection properties
selected: string | string[] = []
multiple: boolean = specs.props.multiple.default
maxMultiple: number = specs.props.maxMultiple.default
range: boolean = specs.props.range.default
// Date constraints
earliest: string | null = specs.props.earliest.default
latest: string | null = specs.props.latest.default
excludedates: Date[] = []
excludeweekdays: string[] = []
// Display options
weeknumbers: boolean = specs.props.weeknumbers.default
withcontrols: boolean = specs.props.withcontrols.default
currentmonth: Date | null = null
today: string | null = null
// Localization 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
// Internal state - selection tracking
private _selected: Date[] = []
private inRange: TDateRangeMap = {}
private rangeHovered: Date | null = null
private get todayDate(): Date {
return this.today ? parseISODateString(this.today) : todayInTz()
}
// Internal state - navigation and display
private year: number = 0
private month: number = 0
private week: number = 0
private currentmonthtouched: boolean = false
// Internal state - keyboard navigation and focus management
private focusedDate: string | null = null
private selectableDates: {
currentDateISO: string
isDisabled: boolean
tabindex: string
}[] = []
private tabIndexSet: number = 0
/**
* Lifecycle methods
*/
protected firstUpdated(_changedProperties: PropertyValues): void {
this.addEventListener('keydown', this.handleKeydown)
}
disconnectedCallback(): void {
this.removeEventListener('keydown', this.handleKeydown)
super.disconnectedCallback()
}
updated(changedProperties: PropertyValues): void {
super.updated(changedProperties)
if (changedProperties.has('selected')) {
this.convertSelected()
}
}
/**
* Date and selection management
*/
private convertSelected() {
if (typeof this.selected === 'string') {
this.selected = this.selected.split(',')
}
if (this.selected.length === 1 && this.selected[0] === '') {
this.selected = []
}
this._selected = convertSelectedToDates(this.selected)
if (this.range && this.selected.length === 2) {
this.inRange = calculateRangeMap(this._selected[0], this._selected[1])
}
this.setCurrentMonth()
}
private setCurrentMonth() {
if (this.currentmonth === null && !this.currentmonthtouched) {
this.currentmonthtouched = true
}
if (this.selected.length && this.selected[0] !== '') {
const d = parseISODateString(this.selected[this.selected.length - 1])
this.currentmonth = isNaN(d.getTime()) ? newDateFromDate(this.todayDate) : d
} else if (this.currentmonth === null) {
this.currentmonth = newDateFromDate(this.todayDate)
}
if (!this.currentmonth || isNaN(this.currentmonth.getTime())) {
this.currentmonth = newDateFromDate(this.todayDate)
}
this.year = this.currentmonth.getFullYear()
this.month = this.currentmonth.getMonth()
}
/**
* Keyboard navigation
*/
private handleKeydown(e: KeyboardEvent) {
const direction = getKeyDirection(e.key)
if (direction !== null) {
this.handleArrowKey(e, direction)
}
}
private handleArrowKey(e: KeyboardEvent, direction: number) {
const target = e.target as HTMLElement
if (shouldIgnoreKeyboardEvent(target)) return
e.preventDefault()
if (!this.focusedDate) {
this.focusOnCurrentDate()
return
}
const date = this.focusedDate ? newDate(this.focusedDate) : newDateYMD(this.year, this.month, 1)
const nextDate = findNextDate(date, direction, this.querySelector.bind(this))
if (nextDate) {
const el = this.querySelector(`button[data-date="${formatISODate(nextDate)}"]`)
if (el instanceof HTMLButtonElement && !el.dataset.disabled) {
this.focusedDate = formatISODate(nextDate)
el.focus()
}
}
}
/**
* Rendering methods
*/
render() {
return html`
<div
class="pkt-calendar ${this.weeknumbers ? 'pkt-cal-weeknumbers' : ''}"
=${this.closeEvent}
=${(e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
this.close()
}
}}
>
<nav class="pkt-cal-month-nav">
${this.renderMonthNavButton('prev')}
${this.renderMonthNav()}
${this.renderMonthNavButton('next')}
</nav>
<table
class="pkt-cal-days pkt-txt-12-medium pkt-calendar__body"
role="grid"
?aria-multiselectable=${this.range || this.multiple}
>
<thead>
${this.renderDayNames()}
</thead>
<tbody>
${this.renderCalendarBody()}
</tbody>
</table>
</div>
`
}
private renderMonthNavButton(direction: 'prev' | 'next'): TemplateResult {
const isPrev = direction === 'prev'
const isAllowed = isPrev ? this.isPrevMonthAllowed() : this.isNextMonthAllowed()
const label = isPrev ? this.prevMonthString : this.nextMonthString
const iconName = isPrev ? 'chevron-thin-left' : 'chevron-thin-right'
const className = isPrev ? 'pkt-calendar__prev-month' : 'pkt-calendar__next-month'
const onClick = isPrev ? () => this.prevMonth() : () => this.nextMonth()
return html`<div>
<button
type="button"
aria-label="${label}"
=${() => isAllowed && onClick()}
=${(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
isAllowed && onClick()
}
}}
class="pkt-btn pkt-btn--tertiary pkt-btn--small pkt-btn--icon-only ${className} ${isAllowed ? '' : 'pkt-invisible'}"
.data-disabled=${!isAllowed ? 'disabled' : nothing}
?aria-disabled=${!isAllowed}
tabindex=${isAllowed ? '0' : '-1'}
>
<pkt-icon class="pkt-btn__icon" name="${iconName}"></pkt-icon>
<span class="pkt-btn__text">${label}</span>
</button>
</div>`
}
private renderDayNames(): TemplateResult {
const days: TemplateResult[] = []
if (this.weeknumbers) {
days.push(html`<th><div class="pkt-calendar__week-number">${this.weekString}</div></th>`)
}
for (let i = 0; i < this.dayStrings.length; i++) {
days.push(
html`<th>
<div class="pkt-calendar__day-name" aria-label="${this.dayStringsLong[i]}">
${this.dayStrings[i]}
</div>
</th>`,
)
}
return html`<tr class="pkt-cal-week-row">${days}</tr>`
}
private renderMonthNav(): TemplateResult {
if (this.withcontrols) {
return 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: Event) => {
e.stopImmediatePropagation()
const target = e.target as HTMLSelectElement
this.changeMonth(this.year, parseInt(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: Event) => {
e.stopImmediatePropagation()
const target = e.target as HTMLInputElement
this.changeMonth(parseInt(target.value), this.month)
}}
.value=${this.year}
/>
</div>`
}
return html`<div class="pkt-txt-16-medium pkt-calendar__month-title" aria-live="polite">
${this.monthStrings[this.month]} ${this.year}
</div>`
}
private getDayViewData(dayCounter: number, today: Date): TDayViewData {
const currentDate = newDateYMD(this.year, this.month, dayCounter)
const currentDateISO = formatISODate(currentDate)
const isToday = currentDateISO === formatISODate(today)
const isSelected = this.selected.includes(currentDateISO)
const isDisabled = this.isDayDisabled(currentDate, isSelected)
const tabindex = this.calculateTabIndex(currentDateISO, isDisabled, dayCounter)
return {
currentDate,
currentDateISO,
isToday,
isSelected,
isDisabled,
ariaLabel: formatReadableDate(currentDate),
tabindex,
}
}
private getDateConstraints(): IDateConstraints {
return {
earliest: this.earliest,
latest: this.latest,
excludedates: this.excludedates,
excludeweekdays: this.excludeweekdays,
}
}
private isDayDisabled(date: Date, isSelected: boolean): boolean {
return checkDayDisabled(date, isSelected, this.getDateConstraints(), {
multiple: this.multiple,
maxMultiple: this.maxMultiple,
selectedCount: this.selected.length,
})
}
private calculateTabIndex(dateISO: string, isDisabled: boolean, dayCounter: number): string {
if (this.focusedDate) {
return this.focusedDate === dateISO && !isDisabled ? '0' : '-1'
}
if (!isDisabled && this.tabIndexSet === 0) {
this.tabIndexSet = dayCounter
return '0'
}
return this.tabIndexSet === dayCounter ? '0' : '-1'
}
private getDayCellClasses(data: TDayViewData) {
const { currentDateISO, isToday, isSelected } = data
const isRangeStart = this.range &&
(this.selected.length === 2 || this.rangeHovered !== null) &&
currentDateISO === this.selected[0]
const isRangeEnd = this.range && this.selected.length === 2 && currentDateISO === this.selected[1]
return {
'pkt-cal-today': isToday,
'pkt-cal-selected': isSelected,
'pkt-cal-in-range': this.inRange[currentDateISO],
'pkt-cal-excluded': this.isExcluded(data.currentDate),
'pkt-cal-in-range-first': isRangeStart,
'pkt-cal-in-range-last': isRangeEnd,
'pkt-cal-range-hover':
this.rangeHovered !== null && currentDateISO === formatISODate(this.rangeHovered),
}
}
private getDayButtonClasses(data: TDayViewData) {
const { currentDateISO, isToday, isSelected, isDisabled } = data
const isRangeStart = this.range &&
(this.selected.length === 2 || this.rangeHovered !== null) &&
currentDateISO === this.selected[0]
const isRangeEnd = this.range && this.selected.length === 2 && currentDateISO === this.selected[1]
return {
'pkt-calendar__date': true,
'pkt-calendar__date--today': isToday,
'pkt-calendar__date--selected': isSelected,
'pkt-calendar__date--disabled': isDisabled,
'pkt-calendar__date--in-range': this.inRange[currentDateISO],
'pkt-calendar__date--in-range-hover':
this.rangeHovered !== null && currentDateISO === formatISODate(this.rangeHovered),
'pkt-calendar__date--range-start': isRangeStart,
'pkt-calendar__date--range-end': isRangeEnd,
}
}
private handleDayFocus(date: Date, dateISO: string): void {
if (this.range && !this.isExcluded(date)) {
this.handleRangeHover(date)
}
this.focusedDate = dateISO
}
private renderDayView(dayCounter: number, today: Date) {
const data = this.getDayViewData(dayCounter, today)
const { currentDate, currentDateISO, isSelected, isDisabled, ariaLabel, tabindex } = data
// Track selectable dates for keyboard navigation
this.selectableDates.push({ currentDateISO, isDisabled, tabindex })
const cellClasses = this.getDayCellClasses(data)
const buttonClasses = this.getDayButtonClasses(data)
return html`<td class=${classMap(cellClasses)}>
<button
type="button"
aria-pressed=${isSelected ? 'true' : 'false'}
?disabled=${isDisabled}
class="pkt-btn pkt-btn--tertiary pkt-btn--small pkt-btn--label-only ${classMap(buttonClasses)}"
=${() => this.range && !this.isExcluded(currentDate) && this.handleRangeHover(currentDate)}
=${() => this.handleDayFocus(currentDate, currentDateISO)}
aria-label="${ariaLabel}"
tabindex=${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>
</button>
</td>`
}
private renderEmptyDayCell(day: number): TemplateResult {
return 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">${day}</span>
</div>
</td>`
}
private renderWeekRow(cells: TemplateResult[]): TemplateResult {
return html`<tr class="pkt-cal-week-row" role="row">${cells}</tr>`
}
private renderCalendarBody() {
const today = this.todayDate
const dimensions = calculateCalendarDimensions(this.year, this.month)
this.selectableDates = []
this.tabIndexSet = 0
let dayCounter = 1
this.week = dimensions.initialWeek
const rows: TemplateResult[] = []
for (let i = 0; i < dimensions.numRows; i++) {
const cells: TemplateResult[] = []
// Add week number if enabled
if (this.weeknumbers) {
cells.push(html`<td class="pkt-cal-week">${this.week}</td>`)
}
this.week++
// Render each day in the week
for (let j = 0; j < DAYS_PER_WEEK; j++) {
const cellType = getCellType(i, j, dayCounter, dimensions)
if (cellType === 'current-month') {
cells.push(this.renderDayView(dayCounter, today))
dayCounter++
} else {
const dayNumber = getDayNumber(cellType, j, dayCounter, dimensions)
cells.push(this.renderEmptyDayCell(dayNumber))
if (cellType === 'next-month') {
dayCounter++
}
}
}
rows.push(this.renderWeekRow(cells))
}
return rows
}
/**
* Date validation
*/
private isExcluded(date: Date): boolean {
return isDateExcluded(date, this.getDateConstraints())
}
/**
* Month navigation
*/
isPrevMonthAllowed(): boolean {
return checkPrevMonthAllowed(this.year, this.month, this.earliest)
}
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(): boolean {
return checkNextMonthAllowed(this.year, this.month, this.latest)
}
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): void {
this.year = typeof year === 'string' ? parseInt(year) : year
this.month = typeof month === 'string' ? parseInt(month) : month
this.currentmonth = newDateFromDate(new Date(this.year, this.month, 1))
this.tabIndexSet = 0
this.focusedDate = null
this.selectableDates = []
}
/**
* Date selection logic
*/
private emptySelected(): void {
this.selected = []
this._selected = []
this.inRange = {}
}
private normalizeSelected(): string[] {
if (typeof this.selected === 'string') {
return this.selected.split(',')
}
return this.selected
}
public addToSelected(selectedDate: Date): void {
this.selected = addToSelection(selectedDate, this.normalizeSelected())
this._selected = convertSelectedToDates(this.selected)
if (this.range && this.selected.length === 2) {
this.convertSelected()
this.close()
}
}
public removeFromSelected(selectedDate: Date): void {
this.selected = removeFromSelection(selectedDate, this.normalizeSelected())
this._selected = convertSelectedToDates(this.selected)
}
public toggleSelected(selectedDate: Date): void {
this.selected = toggleSelection(selectedDate, this.normalizeSelected(), this.maxMultiple)
this._selected = convertSelectedToDates(this.selected)
}
private isRangeAllowed(date: Date): boolean {
return checkRangeAllowed(date, this._selected, this.excludedates, this.excludeweekdays)
}
private handleRangeSelect(selectedDate: Date): Promise<void> {
this.selected = handleRangeSelection(selectedDate, this.normalizeSelected(), {
excludedates: this.excludedates,
excludeweekdays: this.excludeweekdays,
})
this._selected = convertSelectedToDates(this.selected)
if (this.selected.length === 2) {
this.convertSelected()
this.close()
} else if (this.selected.length === 1) {
// Clear inRange markers when starting a new range
this.inRange = {}
}
return Promise.resolve()
}
private handleRangeHover(date: Date): void {
if (
!this.range ||
this._selected.length !== 1 ||
!this.isRangeAllowed(date) ||
this._selected[0] >= date
) {
this.rangeHovered = null
return
}
this.rangeHovered = date
this.inRange = calculateRangeMap(this._selected[0], date)
}
public handleDateSelect(selectedDate: Date | null): Promise<void> {
if (!selectedDate) return Promise.resolve()
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()
}
/**
* Focus management and event handlers
*/
public focusOnCurrentDate(): void {
const currentDateISO = formatISODate(newDateFromDate(this.todayDate))
const el = this.querySelector(`button[data-date="${currentDateISO}"]`)
if (el instanceof HTMLButtonElement) {
this.focusedDate = currentDateISO
el.focus()
return
}
// Fall back to first selectable date
const firstSelectable = this.selectableDates.find((x) => !x.isDisabled)
if (firstSelectable) {
const firstSelectableEl = this.querySelector(
`button[data-date="${firstSelectable.currentDateISO}"]`,
)
if (firstSelectableEl instanceof HTMLButtonElement) {
this.focusedDate = firstSelectable.currentDateISO
firstSelectableEl.focus()
}
}
}
public closeEvent(e: FocusEvent): void {
if (
!this.contains(e.relatedTarget as Node) &&
!(e.target as Element).classList.contains('pkt-invisible')
) {
this.close()
}
}
public close(): void {
this.dispatchEvent(
new CustomEvent('close', {
detail: true,
bubbles: true,
composed: true,
}),
)
}
}
try {
customElement('pkt-calendar')(PktCalendar)
} catch (e) {
console.warn('Forsøker å definere <pkt-calendar>, men den er allerede definert')
}