@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
617 lines (552 loc) • 19.5 kB
text/typescript
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()
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[] = []
label: string = 'Datovelger'
dateformat: string = specs.props.dateformat.default
multiple: boolean = specs.props.multiple.default
maxlength: number | null = null
range: boolean = specs.props.range.default
showRangeLabels: boolean = false
min: string | null = null
max: string | null = null
weeknumbers: boolean = specs.props.weeknumbers.default
withcontrols: boolean = specs.props.withcontrols.default
excludedates: string[] = []
excludeweekdays: string[] = []
currentmonth: string | null = null
today: string | null = null
calendarOpen: boolean = false
timezone: string = 'Europe/Oslo'
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}
-calendar=${(e: CustomEvent) => this.toggleCalendar(e.detail)}
-change=${() => this.onInput()}
-focus=${() => this.onFocus()}
-blur=${(e: CustomEvent) => {
if (!this.calRef.value?.contains(e.detail.relatedTarget as Node)) {
this.onBlur()
}
}}
-validity=${(e: CustomEvent) => this.manageValidity(e.detail)}
-change=${(e: CustomEvent) => {
this.value = e.detail
}}
-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}
-calendar=${(e: CustomEvent) => this.toggleCalendar(e.detail)}
-change=${() => this.onInput()}
-focus=${() => this.onFocus()}
-blur=${(e: CustomEvent) => {
if (!this.calRef.value?.contains(e.detail.relatedTarget as Node)) {
this.onBlur()
}
}}
-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),
)
}}
-validity=${(e: CustomEvent) => this.manageValidity(e.detail)}
-date-input=${(e: CustomEvent) => {
formUtils.validateDateInput(e.detail, this.internals, this.min, this.max, this.strings)
}}
-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)
}
}
}}
-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}
-calendar=${(e: CustomEvent) => this.toggleCalendar(e.detail)}
-change=${() => this.onInput()}
-focus=${() => this.onFocus()}
-blur=${(e: CustomEvent) => {
if (!this.calRef.value?.contains(e.detail.relatedTarget as Node)) {
this.onBlur()
}
}}
-to-selected=${(e: CustomEvent) => this.addToSelected(e.detail)}
-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}
-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),
)
}}
=${() => {
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}
-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')
}