import { customElement, property, state } from 'lit/decorators.js' import { formatISODate, fromISOToDate, parseISODateString } from 'shared-utils/date-utils' import { html, nothing, PropertyValues } from 'lit' import { PktCalendar } from '@/components/calendar/calendar' import { PktInputElement } from '@/base-elements/input-element' import { Ref, createRef, ref } from 'lit/directives/ref.js' import converters from '@/helpers/converters' import specs from 'componentSpecs/datepicker.json' import '@/components/calendar' import '@/components/icon' import '@/components/input-wrapper' import './date-tags' import './datepicker-popup' import './datepicker-single' import './datepicker-range' import './datepicker-multiple' import { slotContent } from '@/directives/slot-content' import { valueUtils, inputTypeUtils, calendarUtils, cssUtils, dateProcessingUtils, formUtils, } from './datepicker-utils' import { valueToArray, arrayToCsv } from 'shared-utils/value-utils' import { sleep } from 'shared-utils/utils' import { isIOS } from 'shared-utils/device-utils' import { PktDatepickerPopup } from './datepicker-popup' import { PktDatepickerSingle } from './datepicker-single' import { PktDatepickerRange } from './datepicker-range' import { PktDatepickerMultiple } from './datepicker-multiple' import { ElementProps } from '@/types/typeUtils' type Props = ElementProps< PktDatepicker, | 'label' | 'dateformat' | 'multiple' | 'maxlength' | 'range' | 'showRangeLabels' | 'min' | 'max' | 'weeknumbers' | 'withcontrols' | 'excludedates' | 'excludeweekdays' | 'currentmonth' | 'calendarOpen' | 'timezone' > export class PktDatepicker extends PktInputElement { /** * Element attributes and properties */ private _valueProperty: string = '' private _valueProcessing = false datepickerPopupRef: Ref = createRef() @property({ type: String, reflect: true }) get value(): string { return this._valueProperty } set value(newValue: string | string[]) { const oldValue = this._valueProperty this._valueProperty = Array.isArray(newValue) ? newValue.join(',') : newValue || '' this.valueChanged(this._valueProperty, oldValue) this.requestUpdate('value', oldValue) } _value: string[] = [] @property({ type: String, reflect: true }) label: string = 'Datovelger' @property({ type: String }) dateformat: string = specs.props.dateformat.default @property({ type: Boolean, reflect: true }) multiple: boolean = specs.props.multiple.default @property({ type: Number, reflect: true }) maxlength: number | null = null @property({ type: Boolean, reflect: true }) range: boolean = specs.props.range.default @property({ type: Boolean, attribute: 'show-range-labels' }) showRangeLabels: boolean = false @property({ type: String, reflect: true }) min: string | null = null @property({ type: String, reflect: true }) max: string | null = null @property({ type: Boolean }) weeknumbers: boolean = specs.props.weeknumbers.default @property({ type: Boolean, reflect: true }) withcontrols: boolean = specs.props.withcontrols.default @property({ converter: converters.csvToArray }) excludedates: string[] = [] @property({ converter: converters.csvToArray }) excludeweekdays: string[] = [] @property({ type: String }) currentmonth: string | null = null @property({ type: String }) today: string | null = null @property({ type: Boolean, reflect: true, attribute: 'calendar-open' }) calendarOpen: boolean = false @property({ type: String }) timezone: string = 'Europe/Oslo' @state() inputClasses = {} /** * Computed properties */ get inputType(): string { return inputTypeUtils.getInputType() } /** * Housekeeping / lifecycle methods */ connectedCallback() { super.connectedCallback() if (this.timezone && this.timezone !== window.pktTz) { window.pktTz = this.timezone } } disconnectedCallback(): void { super.disconnectedCallback() } onInput(): void { this.dispatchEvent(new Event('input', { bubbles: true })) } valueChanged(newValue: string | null, oldValue: string | null): void { if (this._valueProcessing || newValue === oldValue) return this._valueProcessing = true try { const parsedValue = valueToArray(newValue) // For multiple dates, filter out invalid ones to prevent accumulating bad dates // For single/range dates, preserve user input for validation feedback const filteredValue = this.multiple && parsedValue.length > 1 ? valueUtils.filterSelectableDates( parsedValue, this.min, this.max, this.excludedates, this.excludeweekdays, ) : parsedValue if (this.range && !valueUtils.validateRangeOrder(filteredValue)) { this._value = [] this._valueProperty = '' super.valueChanged('', oldValue) return } this._value = filteredValue const parsedValueString = arrayToCsv(filteredValue) if (this._valueProperty !== parsedValueString) { this._valueProperty = parsedValueString } // In range mode, don't dispatch events until both dates are selected if (this.range && filteredValue.length < 2) return super.valueChanged(parsedValueString, oldValue) } finally { this._valueProcessing = false } } attributeChangedCallback(name: string, _old: string | null, value: string | null): void { if (name === 'excludedates' && typeof this.excludedates === 'string') { this.excludedates = valueToArray(value || '') } if (name === 'excludeweekdays' && typeof this.excludeweekdays === 'string') { this.excludeweekdays = valueToArray(value || '') } super.attributeChangedCallback(name, _old, value) } updated(changedProperties: PropertyValues): void { // Re-process value only when multiple/range also changed in the same update cycle, // since attribute initialization may process value before multiple/range are set. // When value is set via JS setter after init, multiple/range are already correct // and the setter's valueChanged() call is sufficient. if ( changedProperties.has('value') && (changedProperties.has('multiple') || changedProperties.has('range')) ) { const newValue = Array.isArray(this.value) ? this.value.join(',') : this.value const oldValue = changedProperties.get('value') const oldValueStr = Array.isArray(oldValue) ? oldValue.join(',') : oldValue this.valueChanged(newValue, oldValueStr) } if (changedProperties.has('multiple')) { if (this.multiple && !Array.isArray(this._value)) { this._value = valueToArray(this.value) } else if (!this.multiple && Array.isArray(this._value)) { this._value = this._value.filter(Boolean) } if (!this.multiple && !this.range && Array.isArray(this._value)) { this._value = [this._value[0] ?? ''] } } this.classList.toggle('pkt-datepicker--medium', this.inputSize === 'medium') this.classList.toggle('pkt-datepicker--small', this.inputSize === 'small') this.classList.toggle('pkt-datepicker--xsmall', this.inputSize === 'xsmall') super.updated(changedProperties) } /** * Element references */ // Override the inputRef and inputRefTo for compatibility get inputRef(): Ref { const element = this.currentInputElement return { value: element } as Ref } get inputRefTo(): Ref { const element = this.currentInputElementTo return { value: element } as Ref } calRef: Ref = createRef() popupRef: Ref = createRef() // Child component refs singleInputRef: Ref = createRef() rangeInputRef: Ref = createRef() multipleInputRef: Ref = createRef() // Getters for backward compatibility with input refs get currentInputElement(): HTMLInputElement | undefined { if (this.multiple) { return this.multipleInputRef.value?.inputElement } else if (this.range) { return this.rangeInputRef.value?.inputElement } else { return this.singleInputRef.value?.inputElement } } get currentInputElementTo(): HTMLInputElement | undefined { if (this.range) { return this.rangeInputRef.value?.inputElementTo } return undefined } get currentButtonElement(): HTMLButtonElement | undefined { if (this.multiple) { return this.multipleInputRef.value?.buttonElement } else if (this.range) { return this.rangeInputRef.value?.buttonElement } else { return this.singleInputRef.value?.buttonElement } } // Override btnRef for compatibility get btnRef(): Ref { const element = this.currentButtonElement return { value: element } as Ref } /** * Rendering */ renderInput() { return html` this.toggleCalendar(e.detail)} @input-change=${() => this.onInput()} @input-focus=${() => this.onFocus()} @input-blur=${(e: CustomEvent) => { if (!this.calRef.value?.contains(e.detail.relatedTarget as Node)) { this.onBlur() } }} @manage-validity=${(e: CustomEvent) => this.manageValidity(e.detail)} @value-change=${(e: CustomEvent) => { this.value = e.detail }} @input-changed=${() => { this.touched = true }} ${ref(this.singleInputRef)} > ` } renderRangeInput() { return html` this.toggleCalendar(e.detail)} @input-change=${() => this.onInput()} @input-focus=${() => this.onFocus()} @input-blur=${(e: CustomEvent) => { if (!this.calRef.value?.contains(e.detail.relatedTarget as Node)) { this.onBlur() } }} @range-blur=${(e: CustomEvent) => { const inputFrom = this.currentInputElement const inputTo = this.currentInputElementTo // Update _value with current input values to sync with calendar if (inputFrom && inputTo) { const fromValue = inputFrom.value const toValue = inputTo.value // If from date is after to date, clear the to date if (fromValue && toValue && fromValue > toValue) { inputTo.value = '' this._value = [fromValue] this.value = fromValue } else { const newValues = [fromValue, toValue].filter(Boolean) if ( newValues.length > 0 && (newValues[0] !== this._value[0] || newValues[1] !== this._value[1]) ) { this._value = newValues this.value = newValues.join(',') } } } dateProcessingUtils.processRangeBlur( e.detail.event, e.detail.values, this.calRef, () => this.clearInputValue(), (input) => this.manageValidity(input), ) }} @manage-validity=${(e: CustomEvent) => this.manageValidity(e.detail)} @validate-date-input=${(e: CustomEvent) => { formUtils.validateDateInput(e.detail, this.internals, this.min, this.max, this.strings) }} @handle-date-select=${(e: CustomEvent) => { const date = fromISOToDate(e.detail) if (date) { const formattedDate = formatISODate(date) // Only update calendar if the date is different from current values if (this._value[0] !== formattedDate && this._value[1] !== formattedDate) { this.calRef?.value?.handleDateSelect(date) } } }} @input-changed=${() => { this.touched = true }} ${ref(this.rangeInputRef)} > ` } renderMultipleInput() { return html` this.toggleCalendar(e.detail)} @input-change=${() => this.onInput()} @input-focus=${() => this.onFocus()} @input-blur=${(e: CustomEvent) => { if (!this.calRef.value?.contains(e.detail.relatedTarget as Node)) { this.onBlur() } }} @add-to-selected=${(e: CustomEvent) => this.addToSelected(e.detail)} @input-changed=${() => { this.touched = true }} ${ref(this.multipleInputRef)} > ` } renderCalendar() { return html` { this.value = dateProcessingUtils.processDateSelection(e.detail, this.multiple, this.range) this._value = e.detail dateProcessingUtils.updateInputValues( this.inputRef, this.inputRefTo, this._value, this.range, this.multiple, (input) => this.manageValidity(input), ) }} @close=${() => { this.onBlur() this.hideCalendar() }} ${ref(this.datepickerPopupRef)} > ` } render() { this.inputClasses = cssUtils.getInputClasses( this.fullwidth, this.showRangeLabels, this.multiple, this.range, this.readonly, this.inputType, this.inputSize, ) return html`
${slotContent(this, 'helptext')}
${this.multiple ? html` { const popup = this.datepickerPopupRef.value const date = fromISOToDate(e.detail) if (popup && date && typeof popup.handleDateSelect === 'function') { popup.handleDateSelect(date) } else { this.calRef.value?.handleDateSelect(date) } }} >` : nothing}
${this.range ? this.renderRangeInput() : this.multiple ? this.renderMultipleInput() : this.renderInput()}
${this.renderCalendar()} ` } /** * Handlers */ handleCalendarPosition() { const hasCounter = this.multiple && !!this.maxlength calendarUtils.handleCalendarPosition(this.popupRef, this.inputRef, hasCounter) } addToSelected = (e: Event | KeyboardEvent) => { const popup = this.datepickerPopupRef.value if (popup && typeof popup.addToSelected === 'function') { return popup.addToSelected(e, this.min, this.max) } return calendarUtils.addToSelected(e, this.calRef, this.min, this.max) } public async showCalendar() { const popup = this.datepickerPopupRef.value this.calendarOpen = true if (popup && typeof popup.show === 'function') { popup.show() if (isIOS()) popup.focusOnCurrentDate() return } await sleep(20) this.handleCalendarPosition() if (isIOS()) { this.calRef.value?.focusOnCurrentDate() } } public hideCalendar() { const popup = this.datepickerPopupRef.value this.calendarOpen = false if (popup && typeof popup.hide === 'function') return popup.hide() } public async toggleCalendar(e: Event) { e.preventDefault() const popup = this.datepickerPopupRef.value if (popup && typeof popup.toggle === 'function') { const wasOpen = !!popup.open popup.toggle() this.calendarOpen = !wasOpen return } this.calendarOpen ? this.hideCalendar() : this.showCalendar() } public clearInputValue() { this._value = [] this.value = '' this.internals.setFormValue(this.value) this.dispatchEvent(new Event('change', { bubbles: true, composed: true })) this.dispatchEvent( new CustomEvent('value-change', { detail: this._value, bubbles: true, composed: true, }), ) } } try { customElement('pkt-datepicker')(PktDatepicker) } catch (e) { console.warn('Forsøker å definere , men den er allerede definert') }