UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

800 lines (748 loc) 26.3 kB
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) } @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) 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} @click=${(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'} @keydown=${this._handlePopupKeydown} @focusout=${(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" @click=${(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} @click=${() => 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} @keydown=${this._handleHoursKeydown} @blur=${this._handleHoursBlur} @focus=${(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} @keydown=${this._handleMinutesKeydown} @blur=${this._handleMinutesBlur} @focus=${(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} @click=${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} @click=${() => 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') }