UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

617 lines (552 loc) 19.5 kB
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<Props> { /** * Element attributes and properties */ private _valueProperty: string = '' private _valueProcessing = false datepickerPopupRef: Ref<PktDatepickerPopup> = 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 === 'value' && this.value !== _old) { this.valueChanged(value, _old) } 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] ?? ''] } } super.updated(changedProperties) } /** * Element references */ // Override the inputRef and inputRefTo for compatibility get inputRef(): Ref<HTMLInputElement> { const element = this.currentInputElement return { value: element } as Ref<HTMLInputElement> } get inputRefTo(): Ref<HTMLInputElement> { const element = this.currentInputElementTo return { value: element } as Ref<HTMLInputElement> } calRef: Ref<PktCalendar> = createRef() popupRef: Ref<HTMLDivElement> = createRef() // Child component refs singleInputRef: Ref<PktDatepickerSingle> = createRef() rangeInputRef: Ref<PktDatepickerRange> = createRef() multipleInputRef: Ref<PktDatepickerMultiple> = 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<HTMLButtonElement> { const element = this.currentButtonElement return { value: element } as Ref<HTMLButtonElement> } /** * Rendering */ renderInput() { return html` <pkt-datepicker-single .value=${this._value[0] ?? ''} .inputType=${this.inputType} .id=${this.id} .min=${this.min} .max=${this.max} .placeholder=${this.placeholder} .readonly=${this.readonly} .disabled=${this.disabled} .inputClasses=${this.inputClasses} .internals=${this.internals} .strings=${this.strings} @toggle-calendar=${(e: CustomEvent) => 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)} ></pkt-datepicker-single> ` } renderRangeInput() { return html` <pkt-datepicker-range .value=${this._value} .inputType=${this.inputType} .id=${this.id} .label=${this.label} .min=${this.min} .max=${this.max} .placeholder=${this.placeholder} .readonly=${this.readonly} .disabled=${this.disabled} .showRangeLabels=${this.showRangeLabels} .inputClasses=${this.inputClasses} .internals=${this.internals} .strings=${this.strings} @toggle-calendar=${(e: CustomEvent) => 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)} ></pkt-datepicker-range> ` } renderMultipleInput() { return html` <pkt-datepicker-multiple .value=${this._value} .inputType=${this.inputType} .id=${this.id} .min=${this.min} .max=${this.max} .placeholder=${this.placeholder} .readonly=${this.readonly} .disabled=${this.disabled} .maxlength=${this.maxlength} .inputClasses=${this.inputClasses} .internals=${this.internals} .strings=${this.strings} @toggle-calendar=${(e: CustomEvent) => 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)} ></pkt-datepicker-multiple> ` } renderCalendar() { return html` <pkt-datepicker-popup class="pkt-contents" ?open=${this.calendarOpen} ?multiple=${this.multiple} ?range=${this.range} ?weeknumbers=${this.weeknumbers} ?withcontrols=${this.withcontrols} .maxMultiple=${this.maxlength} .selected=${this._value} .earliest=${this.min} .latest=${this.max} .excludedates=${Array.isArray(this.excludedates) ? this.excludedates : (this.excludedates as string).split(',')} .excludeweekdays=${this.excludeweekdays} .currentmonth=${this.currentmonth ? parseISODateString(this.currentmonth) : null} .today=${this.today} @date-selected=${(e: CustomEvent) => { 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)} ></pkt-datepicker-popup> ` } render() { this.inputClasses = cssUtils.getInputClasses( this.fullwidth, this.showRangeLabels, this.multiple, this.range, this.readonly, this.inputType, ) return html` <pkt-input-wrapper label="${this.label}" forId="${this.id}-input" ?counter=${this.multiple && !!this.maxlength} .counterCurrent=${this.value ? this._value.length : 0} .counterMaxLength=${this.maxlength} ?disabled=${this.disabled} ?hasError=${this.hasError} ?hasFieldset=${this.hasFieldset} ?inline=${this.inline} ?required=${this.required} ?optionalTag=${this.optionalTag} ?requiredTag=${this.requiredTag} useWrapper=${this.useWrapper} .optionalText=${this.optionalText} .requiredText=${this.requiredText} .tagText=${this.tagText} .errorMessage=${this.errorMessage} .helptext=${this.helptext} .helptextDropdown=${this.helptextDropdown} .helptextDropdownButton=${this.helptextDropdownButton} .ariaDescribedBy=${this.ariaDescribedBy} class="pkt-datepicker" > <div class="pkt-contents" slot="helptext">${slotContent(this, 'helptext')}</div> ${this.multiple ? html`<pkt-date-tags .dates=${this._value} dateformat=${this.dateformat} strings=${this.strings} id-base=${this.id} @date-tag-removed=${(e: CustomEvent) => { 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) } }} ></pkt-date-tags>` : nothing} <div class="pkt-datepicker__inputs ${this.range ? 'pkt-input__range-inputs' : ''}"> ${this.range ? this.renderRangeInput() : this.multiple ? this.renderMultipleInput() : this.renderInput()} </div> </pkt-input-wrapper> ${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 <pkt-datepicker>, men den er allerede definert') }