@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
663 lines (608 loc) • 20.6 kB
text/typescript
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))
export class PktDatepicker extends PktInputElement {
/**
* Element attributes and properties
*/
value: string | string[] = ''
_value: string[] = this.value
? !Array.isArray(this.value)
? this.value.split(',')
: this.value
: []
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
calendarOpen: boolean = false
timezone: string = 'Europe/Oslo'
inputClasses = {}
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)}
=${(e: MouseEvent) => {
e.preventDefault()
this.showCalendar()
}}
?disabled=${this.disabled}
=${(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()
}
}
}}
=${(e: Event) => {
this.onInput()
e.stopImmediatePropagation()
}}
=${() => {
this.onFocus()
if (this.isMobileSafari) {
this.showCalendar()
}
}}
=${(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
}}
=${(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}
=${(e: MouseEvent) => {
e.preventDefault()
this.showCalendar()
}}
=${(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()
}
}
}}
=${(e: Event) => {
this.onInput()
e.stopImmediatePropagation()
}}
=${() => {
this.onFocus()
if (this.isMobileSafari) {
this.showCalendar()
}
}}
=${(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()
}
}}
=${(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}
=${(e: MouseEvent) => {
e.preventDefault()
this.showCalendar()
}}
=${(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()
}
}
}}
=${(e: Event) => {
this.onInput()
e.stopImmediatePropagation()
}}
=${() => {
this.onFocus()
if (this.isMobileSafari) {
this.showCalendar()
}
}}
=${(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)
}
}
}
}}
=${(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)}
=${(e: MouseEvent) => {
e.preventDefault()
this.showCalendar()
}}
=${(e: FocusEvent) => {
if (!this.calRef.value?.contains(e.relatedTarget as Node)) {
this.onBlur()
}
this.addToSelected(e)
}}
=${(e: Event) => {
this.onInput()
e.stopImmediatePropagation()
}}
=${() => {
this.onFocus()
if (this.isMobileSafari) {
this.showCalendar()
}
}}
=${(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()
}
}
}}
=${(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,
)}"
=${() => 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'}"
=${(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}
-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] : ''
}
}
}}
=${() => {
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"
=${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()
}
}