UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

663 lines (608 loc) 20.6 kB
import { classMap } from 'lit/directives/class-map.js' import { ifDefined } from 'lit/directives/if-defined.js' import { customElement, property, state } from 'lit/decorators.js' import { formatISODate, fromISOToDate, fromISOtoLocal, newDate } from '@/utils/dateutils' 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 { repeat } from 'lit/directives/repeat.js' import converters from '@/helpers/converters' import specs from 'componentSpecs/datepicker.json' import '@/components/calendar' import '@/components/icon' import '@/components/input-wrapper' import '@/components/tag' import { PktSlotController } from '@/controllers/pkt-slot-controller' const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @customElement('pkt-datepicker') export class PktDatepicker extends PktInputElement { /** * Element attributes and properties */ @property({ type: String, reflect: true }) value: string | string[] = '' @property({ type: Array }) _value: string[] = this.value ? !Array.isArray(this.value) ? this.value.split(',') : this.value : [] @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 }) 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: Boolean, reflect: true }) calendarOpen: boolean = false @property({ type: String }) timezone: string = 'Europe/Oslo' @state() inputClasses = {} @state() buttonClasses = {} /** * Housekeeping / lifecycle methods */ constructor() { super() this.slotController = new PktSlotController(this, this.helptextSlot) } async connectedCallback() { super.connectedCallback() const ua = navigator.userAgent const isIOS = /iP(hone|od|ad)/.test(ua) this.inputType = isIOS ? 'text' : 'date' document && document.body.addEventListener('click', (e: MouseEvent) => { if ( this.inputRef?.value && this.btnRef?.value && !this.inputRef.value.contains(e.target as Node) && !(this.inputRefTo.value && this.inputRefTo.value.contains(e.target as Node)) && !this.btnRef.value.contains(e.target as Node) && !(e.target as Element).closest('.pkt-calendar-popup') && this.calendarOpen ) { this.onBlur() this.hideCalendar() } }) if (this.value.length && this._value.length === 0) { this._value = !Array.isArray(this.value) ? this.value.split(',') : this.value } this.min = this.min || specs.props.min.default this.max = this.max || specs.props.max.default if (typeof this.excludedates === 'string') { this.excludedates = (this.excludedates as unknown as string).split(',') } if (typeof this.excludeweekdays === 'string') { this.excludeweekdays = (this.excludeweekdays as unknown as string).split(',') } if ((this.multiple || this.range) && this.name && !this.name.endsWith('[]')) { this.name = this.name + '[]' } if (this.calendarOpen) { await sleep(20) this.handleCalendarPosition() } } disconnectedCallback(): void { super.disconnectedCallback() document && document.body.removeEventListener('click', (e: MouseEvent) => { if ( this.inputRef?.value && this.btnRef?.value && !this.inputRef.value.contains(e.target as Node) && !this.btnRef.value.contains(e.target as Node) ) { this.hideCalendar() } }) } attributeChangedCallback(name: string, _old: string | null, value: string | null): void { if (name === 'value') { if (this.range && value?.split(',').length === 1) return if (this.value !== _old) this.valueChanged(value, _old) } if (name === 'excludedates' && typeof this.excludedates === 'string') { this.excludedates = value?.split(',') ?? [] } if (name === 'excludeweekdays' && typeof this.excludeweekdays === 'string') { this.excludeweekdays = value?.split(',') ?? [] } super.attributeChangedCallback(name, _old, value) } updated(changedProperties: PropertyValues): void { if (changedProperties.has('value')) { if (this.range && this.value.length === 1) return this.valueChanged(this.value, changedProperties.get('value')) } super.updated(changedProperties) } /** * Element references */ // When using PktInputElement, we always need to define `inputRef` inputRef: Ref<HTMLInputElement> = createRef() inputRefTo: Ref<HTMLInputElement> = createRef() btnRef: Ref<HTMLButtonElement> = createRef() calRef: Ref<PktCalendar> = createRef() popupRef: Ref<HTMLDivElement> = createRef() helptextSlot: Ref<HTMLElement> = createRef() /** * Rendering */ renderInput() { return html` <input class="${classMap(this.inputClasses)}" .type=${this.inputType} id="${this.id}-input" .value=${this._value[0] ?? ''} min=${ifDefined(this.min)} max=${ifDefined(this.max)} @click=${(e: MouseEvent) => { e.preventDefault() this.showCalendar() }} ?disabled=${this.disabled} @keydown=${(e: KeyboardEvent) => { if (e.key === ',') { this.inputRef.value?.blur() } if (e.key === 'Space' || e.key === ' ') { e.preventDefault() this.toggleCalendar(e) } if (e.key === 'Enter') { const form = this.internals.form as HTMLFormElement if (form) { form.requestSubmit() } else { this.inputRef.value?.blur() } } }} @input=${(e: Event) => { this.onInput() e.stopImmediatePropagation() }} @focus=${() => { this.onFocus() if (this.isMobileSafari) { this.showCalendar() } }} @blur=${(e: FocusEvent) => { if (!this.calRef.value?.contains(e.relatedTarget as Node)) { this.onBlur() } this.manageValidity(e.target as HTMLInputElement) this.value = (e.target as HTMLInputElement).value }} @change=${(e: Event) => { e.stopImmediatePropagation() }} ${ref(this.inputRef)} /> ` } renderRangeInput() { const rangeLabelClasses = { 'pkt-input-prefix': this.showRangeLabels, 'pkt-hide': !this.showRangeLabels, } return html` ${this.showRangeLabels ? html` <div class="pkt-input-prefix">${this.strings.generic.from}</div> ` : nothing} <input class=${classMap(this.inputClasses)} .type=${this.inputType} id="${this.id}-input" .value=${this._value[0] ?? ''} min=${ifDefined(this.min)} max=${ifDefined(this.max)} ?disabled=${this.disabled} @click=${(e: MouseEvent) => { e.preventDefault() this.showCalendar() }} @keydown=${(e: KeyboardEvent) => { if (e.key === ',') { this.inputRef.value?.blur() } if (e.key === 'Space' || e.key === ' ') { e.preventDefault() this.toggleCalendar(e) } if (e.key === 'Enter') { const form = this.internals.form as HTMLFormElement if (form) { form.requestSubmit() } else { this.inputRefTo.value?.focus() } } }} @input=${(e: Event) => { this.onInput() e.stopImmediatePropagation() }} @focus=${() => { this.onFocus() if (this.isMobileSafari) { this.showCalendar() } }} @blur=${(e: Event) => { if ((e.target as HTMLInputElement).value) { this.manageValidity(e.target as HTMLInputElement) const date = fromISOToDate((e.target as HTMLInputElement).value) if (date) { if (this._value[0] !== (e.target as HTMLInputElement).value && this._value[1]) { this.clearInputValue() this.calRef?.value?.handleDateSelect(date) } } } else if (this._value[0]) { this.clearInputValue() } }} @change=${(e: Event) => { e.stopImmediatePropagation() }} ${ref(this.inputRef)} /> <div class="${classMap(rangeLabelClasses)}" id="${this.id}-to-label"> ${this.strings.generic.to} </div> ${!this.showRangeLabels ? html` <div class="pkt-input-separator">–</div> ` : nothing} <input class=${classMap(this.inputClasses)} .type=${this.inputType} id="${this.id}-to" aria-labelledby="${this.id}-to-label" .value=${this._value[1] ?? ''} min=${ifDefined(this.min)} max=${ifDefined(this.max)} ?disabled=${this.disabled} @click=${(e: MouseEvent) => { e.preventDefault() this.showCalendar() }} @keydown=${(e: KeyboardEvent) => { if (e.key === ',') { this.inputRefTo.value?.blur() } if (e.key === 'Space' || e.key === ' ') { e.preventDefault() this.toggleCalendar(e) } if (e.key === 'Enter') { const form = this.internals.form as HTMLFormElement if (form) { form.requestSubmit() } else { this.inputRefTo.value?.blur() } } }} @input=${(e: Event) => { this.onInput() e.stopImmediatePropagation() }} @focus=${() => { this.onFocus() if (this.isMobileSafari) { this.showCalendar() } }} @blur=${(e: FocusEvent) => { if (!this.calRef.value?.contains(e.relatedTarget as Node)) { this.onBlur() } if ((e.target as HTMLInputElement).value) { this.manageValidity(e.target as HTMLInputElement) const val = (e.target as HTMLInputElement).value if (this.min && this.min > val) { this.internals.setValidity( { rangeUnderflow: true }, this.strings.forms.messages.rangeUnderflow, e.target as HTMLInputElement, ) } else if (this.max && this.max < val) { this.internals.setValidity( { rangeOverflow: true }, this.strings.forms.messages.rangeOverflow, e.target as HTMLInputElement, ) } const date = fromISOToDate((e.target as HTMLInputElement).value) if (date) { if (this._value[1] !== formatISODate(date)) { this.calRef?.value?.handleDateSelect(date) } } } }} @change=${(e: Event) => { e.stopImmediatePropagation() }} ${ref(this.inputRefTo)} /> ` } renderMultipleInput() { return html` <input class=${classMap(this.inputClasses)} .type=${this.inputType} id="${this.id}-input" min=${ifDefined(this.min)} max=${ifDefined(this.max)} ?disabled=${this.disabled || (this.maxlength && this._value.length >= this.maxlength)} @click=${(e: MouseEvent) => { e.preventDefault() this.showCalendar() }} @blur=${(e: FocusEvent) => { if (!this.calRef.value?.contains(e.relatedTarget as Node)) { this.onBlur() } this.addToSelected(e) }} @input=${(e: Event) => { this.onInput() e.stopImmediatePropagation() }} @focus=${() => { this.onFocus() if (this.isMobileSafari) { this.showCalendar() } }} @keydown=${(e: KeyboardEvent) => { if (e.key === ',') { e.preventDefault() this.addToSelected(e) } if (e.key === 'Space' || e.key === ' ') { e.preventDefault() this.toggleCalendar(e) } if (e.key === 'Enter') { const form = this.internals.form as HTMLFormElement if (form) { form.requestSubmit() } else { this.inputRef.value?.blur() } } }} @change=${(e: Event) => { e.stopImmediatePropagation() }} ${ref(this.inputRef)} /> ` } renderTags() { return html` <div class="pkt-datepicker__tags" aria-live="polite"> ${!!this._value[0] ? repeat( this._value ?? [], (date) => date, (date) => html` <pkt-tag .id="${this.id + date + '-tag'}" closeTag ariaLabel="${this.strings.calendar.deleteDate} ${fromISOtoLocal( date, this.dateformat, )}" @close=${() => this.calRef.value?.handleDateSelect(fromISOToDate(date))} ><time datetime="${date}">${fromISOtoLocal(date, this.dateformat)}</time></pkt-tag > `, ) : nothing} </div> ` } renderCalendar() { return html`<div class="pkt-calendar-popup pkt-${this.calendarOpen ? 'show' : 'hide'}" @focusout=${(e: FocusEvent) => { if (this.calendarOpen) this.handleFocusOut(e) }} id="${this.id}-popup" ${ref(this.popupRef)} > <pkt-calendar id="${this.id}-calendar" ?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 ? newDate(this.currentmonth) : null} @date-selected=${(e: CustomEvent) => { this.value = !this.multiple && !this.range ? e.detail[0] : e.detail this._value = e.detail if (this.inputRef.value) { if (this.range && this.inputRefTo.value) { this.inputRef.value.value = this._value[0] ?? '' this.inputRefTo.value.value = this._value[1] ?? '' } else if (!this.multiple) { this.inputRef.value.value = this._value.length ? this._value[0] : '' } } }} @close=${() => { this.onBlur() this.hideCalendar() }} ${ref(this.calRef)} ></pkt-calendar> </div>` } render() { this.inputClasses = { 'pkt-input': true, 'pkt-datepicker__input': true, 'pkt-input--fullwidth': this.fullwidth, 'pkt-datepicker--hasrangelabels': this.showRangeLabels, 'pkt-datepicker--multiple': this.multiple, 'pkt-datepicker--range': this.range, } this.buttonClasses = { 'pkt-input-icon': true, 'pkt-btn': true, 'pkt-btn--icon-only': true, 'pkt-btn--tertiary': true, } 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" ${ref(this.helptextSlot)} name="helptext" slot="helptext"></div> ${this.multiple ? this.renderTags() : nothing} <div class="pkt-datepicker__inputs ${this.range && this.showRangeLabels ? 'pkt-input__range-inputs' : ''}" > <div class="pkt-input__container"> ${this.range ? this.renderRangeInput() : this.multiple ? this.renderMultipleInput() : this.renderInput()} <button class="${classMap(this.buttonClasses)}" type="button" @click=${this.toggleCalendar} ?disabled=${this.disabled} ${ref(this.btnRef)} > <pkt-icon name="calendar"></pkt-icon> <span class="pkt-btn__text">${this.strings.calendar.buttonAltText}</span> </button> </div> </div> </pkt-input-wrapper> ${this.renderCalendar()} ` } /** * Handlers */ handleCalendarPosition() { if (this.popupRef.value && this.inputRef.value) { const counter = this.multiple && !!this.maxlength const inputRect = this.inputRef.value.parentElement?.getBoundingClientRect() || this.inputRef.value.getBoundingClientRect() const inputHeight = counter ? inputRect.height + 30 : inputRect.height const popupHeight = this.popupRef.value.getBoundingClientRect().height let top = counter ? 'calc(100% - 30px)' : '100%' if ( inputRect && inputRect.top + popupHeight > window.innerHeight && inputRect.top - popupHeight > 0 ) { top = `calc(100% - ${inputHeight}px - ${popupHeight}px)` } this.popupRef.value.style.top = top } } addToSelected = (e: Event | KeyboardEvent) => { const target = e.target as HTMLInputElement if (!target.value) return const minAsDate = this.min ? newDate(this.min as string) : null const maxAsDate = this.max ? newDate(this.max as string) : null const date = newDate(target.value.split(',')[0]) if ( date && !isNaN(date.getTime()) && (!minAsDate || date >= minAsDate) && (!maxAsDate || date <= maxAsDate) && this.calRef.value ) { this.calRef.value.handleDateSelect(date) } target.value = '' } private handleFocusOut(e: FocusEvent) { if (!this.contains(e.target as Node)) { this.onBlur() this.hideCalendar() } } public async showCalendar() { this.calendarOpen = true await sleep(20) this.handleCalendarPosition() if (this.isMobileSafari) { this.calRef.value?.focusOnCurrentDate() } } public hideCalendar() { this.calendarOpen = false } public async toggleCalendar(e: Event) { e.preventDefault() this.calendarOpen ? this.hideCalendar() : this.showCalendar() } }