@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
800 lines (748 loc) • 26.3 kB
text/typescript
import { html, nothing, PropertyValues } from 'lit'
import { customElement, property, state } from 'lit/decorators.js'
import { Ref, createRef, ref } from 'lit/directives/ref.js'
import { ifDefined } from 'lit/directives/if-defined.js'
import { classMap } from 'lit/directives/class-map.js'
import { PktInputElement } from '@/base-elements/input-element'
import { slotContent } from '@/directives/slot-content'
import { ElementProps } from '@/types/typeUtils'
import strings from '@/translations/no.json'
import { isValidTimeString, timeToMinutes } from 'shared-utils/timepicker/time-utils'
import { getMinuteStep, getHourOptions, getMinuteOptions } from 'shared-utils/timepicker/options'
import { stepTime } from 'shared-utils/timepicker/stepper'
import '@/components/icon'
import '@/components/input-wrapper'
type Props = ElementProps<PktTimepicker, 'value' | 'hidePicker' | 'stepArrows'>
export interface IPktTimepicker {
value?: string
min?: string
max?: string
step?: number
name?: string
id?: string
disabled?: boolean
required?: boolean
hidePicker?: boolean
stepArrows?: boolean
fullwidth?: boolean
label?: string | null
helptext?: string
helptextDropdown?: string
helptextDropdownButton?: string | null
hasError?: boolean
errorMessage?: string
requiredTag?: boolean
requiredText?: string
optionalTag?: boolean
optionalText?: string
tagText?: string | null
inline?: boolean
}
export class PktTimepicker extends PktInputElement<Props> {
hiddenInputRef: Ref<HTMLInputElement> = createRef()
hoursInputRef: Ref<HTMLInputElement> = createRef()
minutesInputRef: Ref<HTMLInputElement> = createRef()
buttonRef: Ref<HTMLButtonElement> = createRef()
/**
* Exposes hiddenInputRef as inputRef so the base class validate() method
* triggers our manageValidity override automatically after every onChange call.
* The hidden input itself is never used for reporting — see manageValidity below.
*/
get inputRef() {
return this.hiddenInputRef
}
/**
* Overrides the base class manageValidity to solve a focusability problem:
* the browser requires the third argument to setValidity (the "anchor") to be
* a visible, focusable element — it focuses that element when navigating to an
* invalid field. Our hidden input[type=time] cannot receive focus, so passing it
* as the anchor would silently fail with "form control is not focusable".
*/
protected override manageValidity(
_input?: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
): void {
const anchor = this.hoursInputRef.value
if (!anchor) return
if (this.required && !this.value) {
this.internals.setValidity({ valueMissing: true }, strings.forms.messages.required, anchor)
this._setAriaInvalid(true)
return
}
if (!this.value) {
this.internals.setValidity({})
this._setAriaInvalid(false)
return
}
const totalMinutes = timeToMinutes(this.value)
const minuteStep = getMinuteStep(this.step)
if (this.min && totalMinutes < timeToMinutes(String(this.min))) {
this.internals.setValidity(
{ rangeUnderflow: true },
strings.forms.messages.rangeUnderflowMin.replace('{min}', String(this.min)),
anchor,
)
this._setAriaInvalid(true)
return
}
if (this.max && totalMinutes > timeToMinutes(String(this.max))) {
this.internals.setValidity(
{ rangeOverflow: true },
strings.forms.messages.rangeOverflowMax.replace('{max}', String(this.max)),
anchor,
)
this._setAriaInvalid(true)
return
}
if (this.step && totalMinutes % minuteStep !== 0) {
const stepMessage =
minuteStep === 60
? strings.forms.messages.timeStepMismatchHour
: minuteStep === 30
? strings.forms.messages.timeStepMismatchHalfHour
: strings.forms.messages.timeStepMismatch.replace(
'{step}',
`${minuteStep}, ${minuteStep * 2}, ${minuteStep * 3}`,
)
this.internals.setValidity({ stepMismatch: true }, stepMessage, anchor)
this._setAriaInvalid(true)
return
}
this.internals.setValidity({})
this._setAriaInvalid(false)
}
value: string = ''
hidePicker: boolean = false
stepArrows: boolean = false
private _hours: string = ''
private _minutes: string = ''
private _isOpen: boolean = false
private _hoursDigitCount: number = 0
private _hoursFirstDigit: number = -1
private _minutesDigitCount: number = 0
private _minutesFirstDigit: number = -1
private _hasFocus: boolean = false
private _setAriaInvalid(invalid: boolean): void {
if (!this.touched && invalid) return
const hours = this.hoursInputRef.value
const minutes = this.minutesInputRef.value
if (invalid) {
hours?.setAttribute('aria-invalid', 'true')
minutes?.setAttribute('aria-invalid', 'true')
} else {
hours?.removeAttribute('aria-invalid')
minutes?.removeAttribute('aria-invalid')
}
}
private _outsideClickHandler = (e: MouseEvent) => {
if (!this.contains(e.target as Node)) {
this._closePopup()
}
}
private _handleComponentFocusIn = (): void => {
if (!this._hasFocus) {
this._hasFocus = true
this.onFocus()
}
}
private _handleComponentFocusOut = (e: FocusEvent): void => {
if (!this.contains(e.relatedTarget as Node)) {
this._hasFocus = false
this.onBlur()
}
}
connectedCallback(): void {
super.connectedCallback()
this.addEventListener('focusin', this._handleComponentFocusIn)
this.addEventListener('focusout', this._handleComponentFocusOut)
document.addEventListener('click', this._outsideClickHandler)
}
disconnectedCallback(): void {
super.disconnectedCallback()
this.removeEventListener('focusin', this._handleComponentFocusIn)
this.removeEventListener('focusout', this._handleComponentFocusOut)
document.removeEventListener('click', this._outsideClickHandler)
}
protected override willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties)
if (changedProperties.has('value')) {
this._syncDisplayFromValue()
}
}
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties)
this.classList.toggle('pkt-timepicker--stepper', this.stepArrows)
this.classList.toggle('pkt-timepicker--fullwidth', this.fullwidth)
if (changedProperties.has('step') && this.step !== null && !this._isValidStep(this.step)) {
// eslint-disable-next-line no-console
console.warn(
`pkt-timepicker: step="${this.step}" er ikke en gyldig verdi. Step må være et multiplum av 60 (hele minutter) eller nøyaktig 3600 (hel time).`,
)
}
}
protected formResetCallback(): void {
super.formResetCallback()
this._hours = ''
this._minutes = ''
this._isOpen = false
}
private _isValidStep(s: number): boolean {
return s === 3600 || (s < 3600 && 3600 % s === 0 && s % 60 === 0)
}
private _syncDisplayFromValue(): void {
const parsed = isValidTimeString(this.value) ? this.value.split(':') : null
if (parsed) {
this._hours = parsed[0]
this._minutes = parsed[1]
} else {
this._hours = ''
this._minutes = ''
}
}
private _syncValueFromDisplay(): void {
if (this._hours !== '' && this._minutes !== '') {
const newValue = `${this._hours}:${this._minutes}`
if (newValue !== this.value) {
this.value = newValue
this.onChange(newValue)
}
} else if (this.value !== '') {
this.value = ''
this.onChange('')
}
}
private get _minuteStep(): number {
return getMinuteStep(this.step)
}
private get _hourOptions(): number[] {
return getHourOptions(this.min, this.max)
}
private get _minuteOptions(): number[] {
return getMinuteOptions(this.step)
}
private _openPopup(): void {
this._isOpen = true
this.updateComplete.then(() => {
this._scrollToSelected()
this._focusSelectedOrFirst('hour')
})
}
private _closePopup(): void {
this._isOpen = false
this._syncValueFromDisplay()
}
private _togglePopup(): void {
this._isOpen ? this._closePopup() : this._openPopup()
}
private _scrollToSelected(): void {
const popup = this.querySelector('.pkt-timepicker-popup')
if (!popup) return
popup.querySelectorAll('.pkt-timepicker-popup__col').forEach((col) => {
const selected = col.querySelector('.pkt-timepicker-popup__option--selected')
if (selected) {
selected.scrollIntoView({ block: 'center' })
}
})
}
private _focusSelectedOrFirst(type: 'hour' | 'minute'): void {
const popup = this.querySelector('.pkt-timepicker-popup')
if (!popup) return
const cols = popup.querySelectorAll('.pkt-timepicker-popup__col')
const col = type === 'hour' ? cols[0] : cols[1]
if (!col) return
const selected = col.querySelector(
'.pkt-timepicker-popup__option--selected',
) as HTMLElement | null
const first = col.querySelector('.pkt-timepicker-popup__option') as HTMLElement | null
;(selected || first)?.focus()
}
private _handleHoursKeydown = (e: KeyboardEvent): void => {
const input = e.target as HTMLInputElement
switch (e.key) {
case 'ArrowUp': {
e.preventDefault()
const h = this._hours !== '' ? parseInt(this._hours, 10) : 0
this._hours = String((h + 1) % 24).padStart(2, '0')
this._syncValueFromDisplay()
this.onInput()
break
}
case 'ArrowDown': {
e.preventDefault()
const h = this._hours !== '' ? parseInt(this._hours, 10) : 0
this._hours = String((h - 1 + 24) % 24).padStart(2, '0')
this._syncValueFromDisplay()
this.onInput()
break
}
case 'ArrowRight':
e.preventDefault()
this.minutesInputRef.value?.focus()
break
case 'Backspace':
case 'Delete':
this._hoursDigitCount = 0
this._hoursFirstDigit = -1
break
case 'Tab':
break
case 'Enter': {
e.preventDefault()
const form = this.internals.form as HTMLFormElement
if (form) form.requestSubmit()
else input.blur()
break
}
default:
if (/^\d$/.test(e.key)) {
e.preventDefault()
const digit = parseInt(e.key, 10)
if (this._hoursDigitCount === 0) {
this._hoursFirstDigit = digit
this._hours = String(digit).padStart(2, '0')
this._hoursDigitCount = 1
this.onInput()
if (digit >= 3) {
this._hoursFirstDigit = -1
this._hoursDigitCount = 0
this._syncValueFromDisplay()
this.minutesInputRef.value?.focus()
}
} else {
const combined = this._hoursFirstDigit * 10 + digit
this._hours =
combined <= 23 ? String(combined).padStart(2, '0') : String(digit).padStart(2, '0')
this._hoursFirstDigit = -1
this._hoursDigitCount = 0
this._syncValueFromDisplay()
this.onInput()
this.minutesInputRef.value?.focus()
}
} else {
e.preventDefault()
}
}
}
private _handleHoursBlur = (): void => {
if (this._hours !== '') {
this._hours = String(parseInt(this._hours, 10)).padStart(2, '0')
}
this._hoursDigitCount = 0
this._hoursFirstDigit = -1
this._syncValueFromDisplay()
}
private _handleMinutesKeydown = (e: KeyboardEvent): void => {
const input = e.target as HTMLInputElement
switch (e.key) {
case 'ArrowUp': {
e.preventDefault()
const m = this._minutes !== '' ? parseInt(this._minutes, 10) : 0
this._minutes = String((m + this._minuteStep) % 60).padStart(2, '0')
this._syncValueFromDisplay()
this.onInput()
break
}
case 'ArrowDown': {
e.preventDefault()
const m = this._minutes !== '' ? parseInt(this._minutes, 10) : 0
this._minutes = String((m - this._minuteStep + 60) % 60).padStart(2, '0')
this._syncValueFromDisplay()
this.onInput()
break
}
case 'ArrowLeft':
e.preventDefault()
this.hoursInputRef.value?.focus()
break
case 'Backspace':
case 'Delete':
this._minutesDigitCount = 0
this._minutesFirstDigit = -1
break
case 'Tab':
break
case 'Enter': {
e.preventDefault()
const form = this.internals.form as HTMLFormElement
if (form) form.requestSubmit()
else input.blur()
break
}
default:
if (/^\d$/.test(e.key)) {
e.preventDefault()
const digit = parseInt(e.key, 10)
if (this._minutesDigitCount === 0) {
this._minutesFirstDigit = digit
this._minutes = String(digit).padStart(2, '0')
this._minutesDigitCount = 1
this.onInput()
if (digit >= 6) {
this._minutesFirstDigit = -1
this._minutesDigitCount = 0
this._syncValueFromDisplay()
}
} else {
const combined = this._minutesFirstDigit * 10 + digit
this._minutes =
combined <= 59 ? String(combined).padStart(2, '0') : String(digit).padStart(2, '0')
this._minutesFirstDigit = -1
this._minutesDigitCount = 0
this._syncValueFromDisplay()
this.onInput()
}
} else {
e.preventDefault()
}
}
}
private _handleMinutesBlur = (): void => {
if (this._minutes !== '') {
this._minutes = String(parseInt(this._minutes, 10)).padStart(2, '0')
}
this._minutesDigitCount = 0
this._minutesFirstDigit = -1
this._syncValueFromDisplay()
}
private _handleOptionClick(value: number, type: 'hour' | 'minute'): void {
const padded = String(value).padStart(2, '0')
if (type === 'hour') {
this._hours = padded
// Keep popup open, move focus to minute column
this.updateComplete.then(() => this._focusSelectedOrFirst('minute'))
} else {
this._minutes = padded
this._closePopup()
this.buttonRef.value?.focus()
}
}
private _focusOptionAndSync(option: HTMLElement | undefined, type: string): void {
if (!option) return
const val = parseInt(option.dataset.value ?? '0', 10)
if (type === 'hour') this._hours = String(val).padStart(2, '0')
else this._minutes = String(val).padStart(2, '0')
option.focus()
}
private _handlePopupKeydown = (e: KeyboardEvent): void => {
const focused = document.activeElement as HTMLElement
const col = focused.closest('.pkt-timepicker-popup__col')
if (!col) return
const options = Array.from(col.querySelectorAll<HTMLElement>('.pkt-timepicker-popup__option'))
const currentIdx = options.indexOf(focused)
const type = focused.dataset.type ?? ''
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
this._focusOptionAndSync(options[Math.min(currentIdx + 1, options.length - 1)], type)
break
case 'ArrowUp':
e.preventDefault()
this._focusOptionAndSync(options[Math.max(currentIdx - 1, 0)], type)
break
case 'Home':
e.preventDefault()
this._focusOptionAndSync(options[0], type)
break
case 'End':
e.preventDefault()
this._focusOptionAndSync(options[options.length - 1], type)
break
case 'ArrowRight':
e.preventDefault()
if (type === 'hour') {
this._focusOptionAndSync(focused, type)
this.updateComplete.then(() => {
this._scrollToSelected()
this._focusSelectedOrFirst('minute')
})
}
break
case 'ArrowLeft':
e.preventDefault()
if (type === 'minute') {
this._focusOptionAndSync(focused, type)
this.updateComplete.then(() => {
this._scrollToSelected()
this._focusSelectedOrFirst('hour')
})
}
break
case 'Enter':
case ' ':
e.preventDefault()
focused.click()
break
case 'Escape':
e.preventDefault()
this._closePopup()
this.buttonRef.value?.focus()
break
}
}
private _stepTime(direction: 1 | -1): void {
const result = stepTime(this._hours, this._minutes, direction, this._minuteStep)
this._hours = result.hours
this._minutes = result.minutes
this._syncValueFromDisplay()
}
private _renderOption(value: number, type: 'hour' | 'minute') {
const strVal = String(value).padStart(2, '0')
const currentVal =
type === 'hour'
? this._hours !== ''
? parseInt(this._hours, 10)
: NaN
: this._minutes !== ''
? parseInt(this._minutes, 10)
: NaN
const isSelected = value === currentVal
return html`
<button
class=${classMap({
'pkt-btn': true,
'pkt-btn--tertiary': true,
'pkt-btn--small': true,
'pkt-btn--label-only': true,
'pkt-timepicker-popup__option': true,
'pkt-timepicker-popup__option--selected': isSelected,
})}
type="button"
role="option"
aria-selected=${isSelected ? 'true' : 'false'}
tabindex=${isSelected ? '0' : '-1'}
data-type=${type}
data-value=${value}
=${(e: Event) => {
e.stopImmediatePropagation()
this._handleOptionClick(value, type)
}}
>
<span class="pkt-btn__text pkt-txt-14-light">${strVal}</span>
</button>
`
}
private _renderPopup() {
return html`
<div
class="pkt-timepicker-popup"
id=${this.id + '-popup'}
?hidden=${!this._isOpen}
role="group"
aria-label=${this.strings.timepicker?.selectTime ?? 'Velg tidspunkt'}
=${this._handlePopupKeydown}
=${(e: FocusEvent) => {
if (!this.querySelector('.pkt-timepicker-popup')?.contains(e.relatedTarget as Node)) {
this._closePopup()
}
}}
>
<div
class="pkt-timepicker-popup__col"
role="listbox"
aria-label=${this.strings.timepicker?.hours ?? 'Timer'}
aria-orientation="vertical"
>
${this._hourOptions.map((h) => this._renderOption(h, 'hour'))}
</div>
<div
class="pkt-timepicker-popup__col"
role="listbox"
aria-label=${this.strings.timepicker?.minutes ?? 'Minutter'}
aria-orientation="vertical"
>
${this._minuteOptions.map((m) => this._renderOption(m, 'minute'))}
</div>
</div>
`
}
private _renderContainer() {
const hoursLabel = this.strings.timepicker?.hours ?? 'Timer'
const minutesLabel = this.strings.timepicker?.minutes ?? 'Minutter'
const hoursAriaLabel = this.label ? `${hoursLabel}, ${this.label}` : hoursLabel
const minutesAriaLabel = this.label ? `${minutesLabel}, ${this.label}` : minutesLabel
return html`
<div
class="pkt-input__container"
=${(e: Event) => {
const target = e.target as HTMLElement
if (target.closest('button, input')) return
this.hoursInputRef.value?.focus()
}}
>
${this.stepArrows
? html`
<button
class="pkt-input-icon pkt-btn pkt-btn--icon-only pkt-btn--tertiary pkt-timepicker__button pkt-timepicker__button--prev"
type="button"
aria-label=${this.strings.timepicker?.prevTime ?? 'Forrige tidspunkt'}
?disabled=${this.disabled}
=${() => this._stepTime(-1)}
>
<pkt-icon name="chevron-thin-left"></pkt-icon>
<span class="pkt-btn__text"
>${this.strings.timepicker?.prevTime ?? 'Forrige tidspunkt'}</span
>
</button>
`
: nothing}
<input
${ref(this.hoursInputRef)}
type="text"
inputmode="numeric"
maxlength="2"
size="2"
class="pkt-input pkt-timepicker__input"
id=${this.id + '-hours'}
data-min="0"
data-max="23"
.value=${this._hours}
placeholder="––"
aria-label=${hoursAriaLabel}
role="spinbutton"
aria-invalid=${this.hasError ? 'true' : nothing}
aria-valuemin="0"
aria-valuemax="23"
aria-valuenow=${this._hours !== '' ? this._hours : nothing}
aria-valuetext=${this._hours !== ''
? `${this._hours} ${hoursLabel.toLowerCase()}`
: nothing}
autocomplete="off"
?disabled=${this.disabled}
=${this._handleHoursKeydown}
=${this._handleHoursBlur}
=${(e: FocusEvent) => {
;(e.target as HTMLInputElement).select()
e.stopImmediatePropagation()
}}
/>
<span class="pkt-timepicker__separator">:</span>
<input
${ref(this.minutesInputRef)}
type="text"
inputmode="numeric"
maxlength="2"
size="2"
class="pkt-input pkt-timepicker__input"
id=${this.id + '-minutes'}
data-min="0"
data-max="59"
.value=${this._minutes}
placeholder="––"
aria-label=${minutesAriaLabel}
role="spinbutton"
aria-invalid=${this.hasError ? 'true' : nothing}
aria-valuemin="0"
aria-valuemax="59"
aria-valuenow=${this._minutes !== '' ? this._minutes : nothing}
aria-valuetext=${this._minutes !== ''
? `${this._minutes} ${minutesLabel.toLowerCase()}`
: nothing}
autocomplete="off"
?disabled=${this.disabled}
=${this._handleMinutesKeydown}
=${this._handleMinutesBlur}
=${(e: FocusEvent) => {
;(e.target as HTMLInputElement).select()
e.stopImmediatePropagation()
}}
/>
${this.hidePicker && !this.stepArrows
? html`<pkt-icon
class="pkt-input-icon pkt-timepicker__icon"
name="clock"
aria-hidden="true"
></pkt-icon>`
: nothing}
${!this.hidePicker && !this.stepArrows
? html`
<button
${ref(this.buttonRef)}
class="pkt-input-icon pkt-btn pkt-btn--icon-only pkt-btn--tertiary pkt-timepicker__button"
type="button"
aria-label=${this.strings.timepicker?.openPicker ?? 'Åpne tidspunkt-velger'}
aria-haspopup="listbox"
aria-expanded=${this._isOpen ? 'true' : 'false'}
aria-controls=${this.id + '-popup'}
?disabled=${this.disabled}
=${this._togglePopup}
>
<pkt-icon name="clock"></pkt-icon>
<span class="pkt-btn__text"
>${this.strings.timepicker?.openPicker ?? 'Åpne tidspunkt-velger'}</span
>
</button>
`
: nothing}
${this.stepArrows
? html`
<button
class="pkt-input-icon pkt-btn pkt-btn--icon-only pkt-btn--tertiary pkt-timepicker__button pkt-timepicker__button--next"
type="button"
aria-label=${this.strings.timepicker?.nextTime ?? 'Neste tidspunkt'}
?disabled=${this.disabled}
=${() => this._stepTime(1)}
>
<pkt-icon name="chevron-thin-right"></pkt-icon>
<span class="pkt-btn__text"
>${this.strings.timepicker?.nextTime ?? 'Neste tidspunkt'}</span
>
</button>
`
: nothing}
<input
${ref(this.hiddenInputRef)}
type="time"
hidden
name=${this.name || this.id}
id=${this.id + '-input'}
.value=${this.value}
min=${ifDefined(this.min ?? undefined)}
max=${ifDefined(this.max ?? undefined)}
step=${ifDefined(this.step ?? undefined)}
?required=${this.required}
?disabled=${this.disabled}
tabindex="-1"
/>
</div>
`
}
render() {
return html`
<pkt-input-wrapper
label="${this.label}"
?disabled=${this.disabled}
?fullwidth=${this.fullwidth}
?hasError=${this.hasError}
?inline=${this.inline}
?optionalTag=${this.optionalTag}
?required=${this.required}
?requiredTag=${this.requiredTag}
useWrapper=${this.useWrapper}
.ariaDescribedBy=${this.ariaDescribedBy}
.errorMessage=${this.errorMessage}
.forId="${this.id + '-hours'}"
.helptext=${this.helptext}
.helptextDropdown=${this.helptextDropdown}
.helptextDropdownButton=${this.helptextDropdownButton}
.optionalText=${this.optionalText}
.requiredText=${this.requiredText}
.tagText=${this.tagText}
>
<div class="pkt-contents" slot="helptext">${slotContent(this, 'helptext')}</div>
${!this.hidePicker && !this.stepArrows
? html`
<div class="pkt-timepicker__anchor">
${this._renderContainer()} ${this._renderPopup()}
</div>
`
: this._renderContainer()}
</pkt-input-wrapper>
`
}
}
try {
customElement('pkt-timepicker')(PktTimepicker)
} catch (e) {
console.warn('Forsøker å definere <pkt-timepicker>, men den er allerede definert')
}