UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

726 lines (631 loc) 22.8 kB
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 @property({ converter: converters.csvToArray }) selected: string | string[] = [] @property({ type: Boolean }) multiple: boolean = specs.props.multiple.default @property({ type: Number, attribute: 'max-multiple' }) maxMultiple: number = specs.props.maxMultiple.default @property({ type: Boolean }) range: boolean = specs.props.range.default // Date constraints @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[] = [] // Display options @property({ type: Boolean }) weeknumbers: boolean = specs.props.weeknumbers.default @property({ type: Boolean }) withcontrols: boolean = specs.props.withcontrols.default @property({ converter: converters.stringToDate }) currentmonth: Date | null = null @property({ type: String }) today: string | null = null // Localization strings @property({ type: Array, attribute: 'day-strings' }) dayStrings: string[] = this.strings.dates.daysShort @property({ type: Array, attribute: 'day-strings-long' }) dayStringsLong: string[] = this.strings.dates.days @property({ type: Array, attribute: 'month-strings' }) monthStrings: string[] = this.strings.dates.months @property({ type: String, attribute: 'week-string' }) weekString: string = this.strings.dates.week @property({ type: String, attribute: 'prev-month-string' }) prevMonthString: string = this.strings.dates.prevMonth @property({ type: String, attribute: 'next-month-string' }) nextMonthString: string = this.strings.dates.nextMonth // Internal state - selection tracking @property({ type: Array }) private _selected: Date[] = [] @state() private inRange: TDateRangeMap = {} @property({ type: Date }) private rangeHovered: Date | null = null private get todayDate(): Date { return this.today ? parseISODateString(this.today) : todayInTz() } // Internal state - navigation and display @property({ type: Number }) private year: number = 0 @property({ type: Number }) private month: number = 0 @property({ type: Number }) private week: number = 0 @state() private currentmonthtouched: boolean = false // Internal state - keyboard navigation and focus management @state() 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' : ''}" @focusout=${this.closeEvent} @keydown=${(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}" @click=${() => isAllowed && onClick()} @keydown=${(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" @change=${(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" @change=${(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)}" @mouseover=${() => this.range && !this.isExcluded(currentDate) && this.handleRangeHover(currentDate)} @focus=${() => this.handleDayFocus(currentDate, currentDateISO)} aria-label="${ariaLabel}" tabindex=${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> </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') }