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' import { TPktInputSize } from 'shared-types/size' type Props = ElementProps 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 inputSize?: TPktInputSize } export class PktTimepicker extends PktInputElement { hiddenInputRef: Ref = createRef() hoursInputRef: Ref = createRef() minutesInputRef: Ref = createRef() buttonRef: Ref = 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) } @property({ type: String, reflect: true }) value: string = '' @property({ type: Boolean, reflect: true, attribute: 'hide-picker' }) hidePicker: boolean = false @property({ type: Boolean, reflect: true, attribute: 'step-arrows' }) stepArrows: boolean = false @state() private _hours: string = '' @state() private _minutes: string = '' @state() 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) this.classList.toggle('pkt-timepicker--medium', this.inputSize === 'medium') this.classList.toggle('pkt-timepicker--small', this.inputSize === 'small') this.classList.toggle('pkt-timepicker--xsmall', this.inputSize === 'xsmall') 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('.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` ` } private _renderPopup() { return html`
{ if (!this.querySelector('.pkt-timepicker-popup')?.contains(e.relatedTarget as Node)) { this._closePopup() } }} >
${this._hourOptions.map((h) => this._renderOption(h, 'hour'))}
${this._minuteOptions.map((m) => this._renderOption(m, 'minute'))}
` } 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`
{ const target = e.target as HTMLElement if (target.closest('button, input')) return this.hoursInputRef.value?.focus() }} > ${this.stepArrows ? html` ` : nothing} { ;(e.target as HTMLInputElement).select() e.stopImmediatePropagation() }} /> : { ;(e.target as HTMLInputElement).select() e.stopImmediatePropagation() }} /> ${this.hidePicker && !this.stepArrows ? html`` : nothing} ${!this.hidePicker && !this.stepArrows ? html` ` : nothing} ${this.stepArrows ? html` ` : nothing}
` } render() { return html`
${slotContent(this, 'helptext')}
${!this.hidePicker && !this.stepArrows ? html`
${this._renderContainer()} ${this._renderPopup()}
` : this._renderContainer()}
` } } try { customElement('pkt-timepicker')(PktTimepicker) } catch (e) { console.warn('Forsøker å definere , men den er allerede definert') }