import { customElement, property, state } from "lit/decorators.js"; import { RegistryConsumer } from "../../hierarchy/consumers/RegistryConsumer"; import { css, CSSResultGroup, html, PropertyValues } from "lit"; import { ThermalMinmaxOrUndefined, ThermalRangeOrUndefined } from "@labir/core"; import { t } from "i18next"; import { T } from "../../translations/Languages"; import { booleanConverter } from "../../utils/converters/booleanConverter"; @customElement("registry-range-form") export class RegistryRangeForm extends RegistryConsumer { @property({reflect: true, converter: booleanConverter(true)}) public stacked: boolean = false; @state() protected min: number | undefined; @state() protected max: number | undefined; @state() protected from: number | undefined; @state() protected to: number | undefined; @state() protected step: number = 1; @state() protected availableSteps: number[] = [0.01, 0.1, 0.5, 1, 5, 10]; @state() protected inputValues: { from: string; to: string } = { from: "", to: "" }; @state() protected isUpdatingFromRegistry: boolean = false; private debounceTimer: number | undefined; @state() protected hasHistogram: boolean = false; protected firstUpdated(_changedProperties: PropertyValues): void { super.firstUpdated(_changedProperties); this.hydrate(); } disconnectedCallback(): void { super.disconnectedCallback(); this.dehydrate(); if (this.debounceTimer) { clearTimeout(this.debounceTimer); } } private hydrate(): void { if (this.registry === undefined) { return; } // Update initial state this.recieveMinmax( this.registry.minmax.value ); this.recieveRange( this.registry.range.value ); // Register internal listeners this.registry.minmax.addListener(this.UUID, this.recieveMinmax.bind(this)); this.registry.range.addListener(this.UUID, this.recieveRange.bind(this)); this.registry.histogram.addListener(this.UUID, (value) => { this.hasHistogram = !!value; }); } private dehydrate(): void { if (this.registry === undefined) { return; } // Remove listeners this.registry.minmax.removeListener(this.UUID); this.registry.range.removeListener(this.UUID); } private recieveMinmax( value: ThermalMinmaxOrUndefined ): void { if ( value ) { if (this.min !== value.min ) this.min = value.min; if (this.max !== value.max ) this.max = value.max; } else { this.min = undefined; this.max = undefined; this.from = undefined; this.to = undefined; this.inputValues = { from: "", to: "" }; } } private recieveRange( value: ThermalRangeOrUndefined ): void { this.isUpdatingFromRegistry = true; if ( value ) { if (this.from !== value.from ) { this.from = value.from; this.inputValues = { ...this.inputValues, from: value.from?.toFixed(2) ?? "" }; } if (this.to !== value.to ) { this.to = value.to; this.inputValues = { ...this.inputValues, to: value.to?.toFixed(2) ?? "" }; } } else { this.from = undefined; this.to = undefined; this.inputValues = { from: "", to: "" }; } this.isUpdatingFromRegistry = false; } private updateFrom( value: number | undefined ): void { if ( this.registry && value !== undefined && this.to !== undefined) { this.from = value; this.registry.range.imposeRange( { from: value, to: this.to } ); } } private updateTo( value: number | undefined ): void { if ( this.registry && value !== undefined && this.from !== undefined) { this.to = value; this.registry.range.imposeRange( { from: this.from, to: value } ); } } private debouncedUpdate(type: 'from' | 'to', value: string): void { if (this.debounceTimer) { clearTimeout(this.debounceTimer); } this.debounceTimer = window.setTimeout(() => { if (this.isUpdatingFromRegistry) return; const numValue = parseFloat(value); if (isNaN(numValue)) return; if (type === 'from' && this.isValidFromValue(numValue)) { this.updateFrom(numValue); } else if (type === 'to' && this.isValidToValue(numValue)) { this.updateTo(numValue); } }, 300); } private isValidFromValue(value: number): boolean { if (this.min !== undefined && value < this.min) return false; if (this.to !== undefined && value > this.to) return false; return true; } private isValidToValue(value: number): boolean { if (this.max !== undefined && value > this.max) return false; if (this.from !== undefined && value < this.from) return false; return true; } private canStepFrom(direction: 'up' | 'down'): boolean { if (this.from === undefined) return false; const newValue = direction === 'up' ? this.from + this.step : this.from - this.step; return this.isValidFromValue(newValue); } private canStepTo(direction: 'up' | 'down'): boolean { if (this.to === undefined) return false; const newValue = direction === 'up' ? this.to + this.step : this.to - this.step; return this.isValidToValue(newValue); } private canSetMin(): boolean { return this.min !== undefined && this.to !== undefined && this.min <= this.to && this.from !== this.min; } private canSetMax(): boolean { return this.max !== undefined && this.from !== undefined && this.max >= this.from && this.to !== this.max; } private stepFrom(direction: 'up' | 'down'): void { if (this.from === undefined || !this.canStepFrom(direction)) return; const newValue = direction === 'up' ? this.from + this.step : this.from - this.step; // Zaokrouhlit na 2 desetinná místa pro eliminaci floating point chyb const roundedValue = parseFloat(newValue.toFixed(2)); this.inputValues.from = roundedValue.toFixed(2); this.updateFrom(roundedValue); } private stepTo(direction: 'up' | 'down'): void { if (this.to === undefined || !this.canStepTo(direction)) return; const newValue = direction === 'up' ? this.to + this.step : this.to - this.step; // Zaokrouhlit na 2 desetinná místa pro eliminaci floating point chyb const roundedValue = parseFloat(newValue.toFixed(2)); this.inputValues.to = roundedValue.toFixed(2); this.updateTo(roundedValue); } private setMinValue(): void { if (!this.canSetMin() || this.min === undefined) return; this.inputValues.from = this.min.toFixed(2); this.updateFrom(this.min); } private setMaxValue(): void { if (!this.canSetMax() || this.max === undefined) return; this.inputValues.to = this.max.toFixed(2); this.updateTo(this.max); } private setStep(newStep: number): void { this.step = newStep; } private roundToNearestInteger(type: 'from' | 'to'): void { const currentValue = type === 'from' ? this.from : this.to; if (currentValue === undefined) return; const rounded = Math.round(currentValue); let safeValue = rounded; if ( type === "from" && rounded < ( this.min! ) ) { safeValue = Math.ceil( currentValue ); } if ( type === "to" && rounded > ( this.max! ) ) { safeValue = Math.floor( currentValue ); } console.log( rounded, safeValue ); const isValid = type === 'from' ? this.isValidFromValue(safeValue) : this.isValidToValue(safeValue); if (isValid) { if (type === 'from') { this.inputValues.from = safeValue.toFixed(2); this.updateFrom(safeValue); } else { this.inputValues.to = safeValue.toFixed(2); this.updateTo(safeValue); } } } private getAvailableSteps(): number[] { return this.availableSteps.filter(step => { // Step je dostupný, pokud alespoň jeden z inputů může provést krok v obou směrech const fromCanStep = this.from !== undefined && ( this.isValidFromValue(this.from + step) || this.isValidFromValue(this.from - step) ); const toCanStep = this.to !== undefined && ( this.isValidToValue(this.to + step) || this.isValidToValue(this.to - step) ); return fromCanStep || toCanStep; }); } private isWholeNumber(value: number | undefined): boolean { return value !== undefined && Math.round(value) === value; } private getClosestValidValue(inputValue: string, type: 'from' | 'to'): number | undefined { // Pokud je input prázdný nebo nevalidní, vrátíme současnou hodnotu if (!inputValue.trim()) { return type === 'from' ? this.from : this.to; } const numValue = parseFloat(inputValue); // Pokud není číslo, vrátíme současnou hodnotu if (isNaN(numValue)) { return type === 'from' ? this.from : this.to; } // Najdeme nejbližší validní hodnotu v rozsahu if (type === 'from') { const minBound = this.min ?? Number.NEGATIVE_INFINITY; const maxBound = this.to ?? Number.POSITIVE_INFINITY; if (numValue < minBound) return minBound; if (numValue > maxBound) return maxBound; return numValue; } else { const minBound = this.from ?? Number.NEGATIVE_INFINITY; const maxBound = this.max ?? Number.POSITIVE_INFINITY; if (numValue < minBound) return minBound; if (numValue > maxBound) return maxBound; return numValue; } } private handleInputBlur(type: 'from' | 'to', inputValue: string): void { const correctedValue = this.getClosestValidValue(inputValue, type); if (correctedValue !== undefined) { // Aktualizujeme zobrazovanou hodnotu this.inputValues = { ...this.inputValues, [type]: correctedValue.toFixed(2) }; // Aktualizujeme registry if (type === 'from') { this.updateFrom(correctedValue); } else { this.updateTo(correctedValue); } } } static styles?: CSSResultGroup | undefined = css` :host { font-family: inherit; font-style: normal; font-size: var(--font-size); display: flex !important; flex-wrap: wrap; flex-direction: var( --thermal-direction, row ); gap: .5em; } .fields { display: flex; flex-wrap: no-wrap; gap: 0em; } .fields__buttons { thermal-btn { min-height: 2em; flex-grow: var(--thermal-collapsible-grow, 0); } } .fields__separated { gap: .5em; } .separator { width: .5em; &.separator__line { display: flex; align-items: center; &::after { content: ""; display: block; height: 1px; width: 100%; background: var( --thermal-slate ); } } } .input-group { position: relative; } .input-group-inner { display: flex; align-items: stretch; height: 2em; position: relative; z-index: 1; } .input-group-outer { position: absolute; z-index: 0; text-align: center; width: 100%; font-size: .75em; height: 2em; background: var( --thermal-slate-light ); border: 1px solid var( --thermal-slate ); opacity: 0; transition: all .25s ease-in-out; box-sizing: border-box; display: flex; align-items: center; justify-content: center; gap: .25em; &.input-group-outer__top { top: 3px; padding-bottom: .5em; border-radius: var( --thermal-radius ) var( --thermal-radius ) 0 0; } &.input-group-outer__bottom { bottom: 3px; padding-top: .5em; border-radius: 0 0 var( --thermal-radius ) var( --thermal-radius ); } } .input-group:focus-within .input-group-outer { opacity: 1; &.input-group-outer__top { top: -1.5em; } &.input-group-outer__bottom { bottom: -1.5em; } } .input-group.is-whole-number .input-group-outer__bottom { opacity: 0; bottom: 3px; } .input-group button, .input-group aside, .input-group input { border: 0; border-top: 1px solid var( --thermal-slate ); border-bottom: 1px solid var( --thermal-slate ); color: var( --thermal-foreground ); background: var( --thermal-background ); font-family: inherit; font-size: 1em; line-height: 1em; transition: all .25s ease-in-out; } .input-group-inner input, .input-group-inner aside { display: block; vertical-align: middle; } .input-group-inner aside { display: flex; align-items: center; justify-content: center; padding-left: .3em; } .input-group-inner input { outline: 0; padding: 0; margin: 0; text-align: right; width: 3.5em; &:hover, &:focus { color: var( --thermal-primary ); } &::-webkit-outer-spin-button, &::-webkit-inner-spin-button { /* display: none; <- Crashes Chrome on hover */ -webkit-appearance: none; margin: 0; /* <-- Apparently some margin are still there even though it's hidden */ } &[type=number] { -moz-appearance:textfield; /* Firefox */ } } .input-group-inner > button { cursor: pointer; outline: 0; display: flex; align-items: center; justify-content: center; &.left { border-right: 1px solid var( --thermal-slate-light ); } &.right { border-left: 1px solid var( --thermal-slate-light ); } &:hover, &:focus { background: var( --thermal-slate-light ); } &:disabled { cursor: not-allowed; color: var( --thermal-slate-light ); &:hover, &:focus { background: var( --thermal-background ); } } &:first-child { border-left: 1px solid var( --thermal-slate ); border-radius: var( --thermal-radius ) 0 0 var( --thermal-radius ); } &:last-child { border-right: 1px solid var( --thermal-slate ); border-radius: 0 var( --thermal-radius ) var( --thermal-radius ) 0; } svg { display: block; } } .step-button { color: var( --thermal-slate ) !important; cursor: pointer; font-size: .7em; padding: .2em .3em; transition: all .25s ease-in-out; background: transparent !important; border: none !important; &:hover:not(:disabled) { color: var( --thermal-primary ); } &.active { font-weight: bold; color: var( --thermal-foreground ) !important; } &:disabled { opacity: 0.3; cursor: not-allowed; } } .round-button { color: var( --thermal-foreground ); cursor: pointer; font-size: .8em; padding: .3em .6em; transition: all .25s ease-in-out; border: 0 !important; background: transparent !important; &:hover:not(:disabled) { color: var( --thermal-primary ); } &:disabled { opacity: 0.3; cursor: not-allowed; } } `; protected renderInput( type: 'from' | 'to', before: unknown = undefined, after: unknown = undefined, ): unknown { const value = type === 'from' ? this.from : this.to; const inputValue = this.inputValues[type]; const canStepDown = type === 'from' ? this.canStepFrom('down') : this.canStepTo('down'); const canStepUp = type === 'from' ? this.canStepFrom('up') : this.canStepTo('up'); const availableSteps = this.getAvailableSteps(); const isWhole = this.isWholeNumber(value); return html`
${availableSteps.map(stepValue => html` `)}
${before} { const target = e.target as HTMLInputElement; this.inputValues = { ...this.inputValues, [type]: target.value }; this.debouncedUpdate(type, target.value); }} @blur=${(e: FocusEvent) => { const target = e.target as HTMLInputElement; this.handleInputBlur(type, target.value); }} @keydown=${(e: KeyboardEvent) => { if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault(); const direction = e.key === 'ArrowUp' ? 'up' : 'down'; if (type === 'from') { this.stepFrom(direction); } else { this.stepTo(direction); } } }} > ${after}
`; } protected render(): unknown { return html`
${this.renderInput( 'from', html``, undefined )}
${this.renderInput( 'to', undefined, html`` )}
{ this.registry.range.applyMinmax(); }} style="padding: 0 0.5em; display: flex; align-items: center; justify-content: center;" disabled="${(this.canSetMin() || this.canSetMax()) ? "false" : "true"}" > { this.registry.range.applyAuto(); }} disabled="${this.hasHistogram ? "false" : "true"}" style="padding: 0 0.5em; display: flex; align-items: center; justify-content: center;" >
`; } }