diff --git a/projects/demo/src/modules/components/input-range/examples/5/index.ts b/projects/demo/src/modules/components/input-range/examples/5/index.ts index c9c1610207a1..191145f0a1c7 100644 --- a/projects/demo/src/modules/components/input-range/examples/5/index.ts +++ b/projects/demo/src/modules/components/input-range/examples/5/index.ts @@ -21,8 +21,10 @@ export class TuiInputRangeExample5 { readonly keySteps: TuiKeySteps = [ // [percent, value] + [0, this.min], [25, 10_000], [50, 100_000], [75, 500_000], + [100, this.max], ]; } diff --git a/projects/demo/src/modules/components/range/examples/4/index.html b/projects/demo/src/modules/components/range/examples/4/index.html index 7f0d664bfaa3..2661650ae154 100644 --- a/projects/demo/src/modules/components/range/examples/4/index.html +++ b/projects/demo/src/modules/components/range/examples/4/index.html @@ -3,6 +3,7 @@ new size="m" class="range" + [min]="min" [max]="max" [keySteps]="keySteps" [steps]="2 * segments" diff --git a/projects/demo/src/modules/components/range/examples/4/index.ts b/projects/demo/src/modules/components/range/examples/4/index.ts index 005dc974d4c9..3a1e089c007e 100644 --- a/projects/demo/src/modules/components/range/examples/4/index.ts +++ b/projects/demo/src/modules/components/range/examples/4/index.ts @@ -11,6 +11,7 @@ import {TuiKeySteps} from '@taiga-ui/kit'; encapsulation, }) export class TuiRangeExample4 { + readonly min = 0; readonly max = 1_000_000; readonly ticksLabels = ['0', '10K', '100K', '500k', '1000K']; readonly segments = this.ticksLabels.length - 1; @@ -19,8 +20,10 @@ export class TuiRangeExample4 { readonly keySteps: TuiKeySteps = [ // [percent, value] + [0, this.min], [25, 10_000], [50, 100_000], [75, 500_000], + [100, this.max], ]; } diff --git a/projects/kit/components/range/range.component.ts b/projects/kit/components/range/range.component.ts index 148fd6da7298..7166cdf17ccd 100644 --- a/projects/kit/components/range/range.component.ts +++ b/projects/kit/components/range/range.component.ts @@ -17,19 +17,30 @@ import { } from '@angular/core'; import {NgControl} from '@angular/forms'; import { + clamp, EMPTY_QUERY, isNativeFocusedIn, nonNegativeFiniteAssertion, + quantize, + round, TUI_FOCUSABLE_ITEM_ACCESSOR, + tuiAssert, tuiDefaultProp, TuiFocusableElementAccessor, TuiNativeFocusableElement, tuiPure, } from '@taiga-ui/cdk'; -import {AbstractTuiSlider} from '@taiga-ui/kit/abstract'; +import {TuiSizeS} from '@taiga-ui/core'; +import {AbstractTuiSlider, SLIDER_KEYBOARD_STEP} from '@taiga-ui/kit/abstract'; import {TuiSliderComponent} from '@taiga-ui/kit/components/slider'; +import {TUI_FLOATING_PRECISION} from '@taiga-ui/kit/constants'; import {TUI_FROM_TO_TEXTS} from '@taiga-ui/kit/tokens'; import {TuiKeySteps} from '@taiga-ui/kit/types'; +import { + tuiCheckKeyStepsHaveMinMaxPercents, + tuiKeyStepValueToPercentage, + tuiPercentageToKeyStepValue, +} from '@taiga-ui/kit/utils'; import {Observable} from 'rxjs'; /** @@ -59,6 +70,10 @@ export class TuiNewRangeDirective {} }, ], }) +/** + * `AbstractTuiSlider` includes all legacy code (it can be deleted in v3.0) + * TODO replace `extends AbstractTuiSlider<[number, number]>` by `extends AbstractTuiControl<[number, number]> implements TuiWithOptionalMinMax, TuiFocusableElementAccessor` + */ export class TuiRangeComponent extends AbstractTuiSlider<[number, number]> implements TuiFocusableElementAccessor @@ -76,7 +91,7 @@ export class TuiRangeComponent /** * TODO: think about replacing this props by `step` (to be like native slider). - * It can be easy after refactor of keySteps. + * It can be done after removing backward compatibility code inside {@link computePureKeySteps} in v3.0 */ @Input() @tuiDefaultProp() @@ -84,12 +99,25 @@ export class TuiRangeComponent /** * TODO: think about replacing this props by `step` (to be like native slider). - * It can be easy after refactor of keySteps. + * It can be done after removing backward compatibility code inside {@link computePureKeySteps} in v3.0 * */ @Input() @tuiDefaultProp(nonNegativeFiniteAssertion, 'Quantum must be a non-negative number') quantum = 0; + @Input() + @HostBinding('attr.data-size') + @tuiDefaultProp() + size: TuiSizeS = 'm'; + + @Input() + @tuiDefaultProp() + segments = 0; + + @Input() + @tuiDefaultProp() + keySteps: TuiKeySteps | null = null; + @ViewChildren(TuiSliderComponent, {read: ElementRef}) slidersRefs: QueryList> = EMPTY_QUERY; @@ -127,18 +155,26 @@ export class TuiRangeComponent return isNativeFocusedIn(this.elementRef.nativeElement); } + get fractionStep(): number { + if (this.steps) { + return 1 / this.steps; + } + + return this.quantum ? this.quantum / (this.max - this.min) : SLIDER_KEYBOARD_STEP; + } + get computedKeySteps(): TuiKeySteps { return this.computePureKeySteps(this.keySteps, this.min, this.max); } @HostBinding('style.--left.%') get left(): number { - return 100 * this.getFractionFromValue(this.value[0]); + return this.getPercentageFromValue(this.value[0]); } @HostBinding('style.--right.%') get right(): number { - return 100 - 100 * this.getFractionFromValue(this.value[1]); + return 100 - this.getPercentageFromValue(this.value[1]); } @HostListener('focusin', ['true']) @@ -163,8 +199,8 @@ export class TuiRangeComponent const activeThumbElement = isRightThumb ? rightThumbElement : leftThumbElement; const previousValue = isRightThumb ? this.value[1] : this.value[0]; /** @bad TODO think about a solution without twice conversion */ - const previousFraction = this.getFractionFromValue(previousValue); - const newFractionValue = previousFraction + coefficient * this.computedStep; + const previousFraction = this.getPercentageFromValue(previousValue) / 100; + const newFractionValue = previousFraction + coefficient * this.fractionStep; this.processValue(this.getValueFromFraction(newFractionValue), isRightThumb); @@ -185,6 +221,33 @@ export class TuiRangeComponent this.lastActiveThumb = right ? 'right' : 'left'; } + fractionGuard(fraction: number): number { + return clamp(quantize(fraction, this.fractionStep), 0, 1); + } + + getValueFromFraction(fraction: number): number { + const percentage = this.fractionGuard(fraction) * 100; + + return tuiPercentageToKeyStepValue(percentage, this.computedKeySteps); + } + + getPercentageFromValue(value: number): number { + return tuiKeyStepValueToPercentage(value, this.computedKeySteps); + } + + protected valueGuard(value: number): number { + return clamp( + this.quantum + ? round( + Math.round(value / this.quantum) * this.quantum, + TUI_FLOATING_PRECISION, + ) + : value, + this.min, + this.max, + ); + } + protected getFallbackValue(): [number, number] { return [0, 0]; } @@ -195,6 +258,19 @@ export class TuiRangeComponent min: number, max: number, ): TuiKeySteps { + if (keySteps && tuiCheckKeyStepsHaveMinMaxPercents(keySteps)) { + return keySteps; + } + + // TODO replace all function by `return keySteps || [[0, min], [100, max]]` in v3.0 + tuiAssert.assert( + !keySteps, + '\n' + + 'Input property [keySteps] should contain min and max percents.\n' + + 'We have taken [min] and [max] properties of your component for now (but it will not work in v3.0).\n' + + 'See example how properly use [keySteps]: https://taiga-ui.dev/components/range#key-steps', + ); + return [[0, min], ...(keySteps || []), [100, max]]; } diff --git a/projects/kit/components/range/range.template.html b/projects/kit/components/range/range.template.html index dd4bc884255e..f5862a593e28 100644 --- a/projects/kit/components/range/range.template.html +++ b/projects/kit/components/range/range.template.html @@ -33,7 +33,7 @@
{ [steps]="steps" [segments]="segments" [quantum]="quantum" + [keySteps]="keySteps" > `, @@ -35,6 +37,7 @@ describe('Range', () => { segments = 10; steps = 10; quantum = 0; + keySteps: TuiKeySteps | null = null; } let fixture: ComponentFixture; @@ -225,5 +228,72 @@ describe('Range', () => { expect(testComponent.testValue.value[0]).toBe(3); }); }); + + describe('keySteps', () => { + beforeEach(() => { + testComponent.keySteps = [ + [0, 0], + [25, 10_000], + [50, 100_000], + [75, 500_000], + [100, 1_000_000], + ]; + testComponent.testValue.setValue([0, 0]); + fixture.detectChanges(); + }); + + const testsContexts = [ + { + value: [0, 10_000], + leftOffset: '0%', + rightOffset: '75%', + }, + { + value: [10_000, 10_000], + leftOffset: '25%', + rightOffset: '75%', + }, + { + value: [10_000, 100_000], + leftOffset: '25%', + rightOffset: '50%', + }, + { + value: [100_000, 100_000], + leftOffset: '50%', + rightOffset: '50%', + }, + { + value: [100_000, 500_000], + leftOffset: '50%', + rightOffset: '25%', + }, + { + value: [500_000, 500_000], + leftOffset: '75%', + rightOffset: '25%', + }, + { + value: [500_000, 750_000], + leftOffset: '75%', + rightOffset: '12.5%', + }, + { + value: [750_000, 1_000_000], + leftOffset: '87.5%', + rightOffset: '0%', + }, + ] as const; + + for (const {value, leftOffset, rightOffset} of testsContexts) { + it(`${JSON.stringify(value)}`, () => { + testComponent.testValue.setValue(value); + fixture.detectChanges(); + + expect(getFilledRangeOffeset().left).toBe(leftOffset); + expect(getFilledRangeOffeset().right).toBe(rightOffset); + }); + } + }); }); }); diff --git a/projects/kit/components/slider/slider-key-steps.directive.ts b/projects/kit/components/slider/slider-key-steps.directive.ts index 894e4ab60b19..8e5e712897e2 100644 --- a/projects/kit/components/slider/slider-key-steps.directive.ts +++ b/projects/kit/components/slider/slider-key-steps.directive.ts @@ -15,13 +15,16 @@ import {NgControl} from '@angular/forms'; import { AbstractTuiControl, isNativeFocused, - round, tuiDefaultProp, TuiFocusableElementAccessor, typedFromEvent, } from '@taiga-ui/cdk'; -import {TUI_FLOATING_PRECISION} from '@taiga-ui/kit/constants'; import {TuiKeySteps} from '@taiga-ui/kit/types'; +import { + tuiCheckKeyStepsHaveMinMaxPercents, + tuiKeyStepValueToPercentage, + tuiPercentageToKeyStepValue, +} from '@taiga-ui/kit/utils'; import {map} from 'rxjs/operators'; import {TuiSliderComponent} from './slider.component'; @@ -40,7 +43,10 @@ export class TuiSliderKeyStepsDirective implements TuiFocusableElementAccessor { @Input() - @tuiDefaultProp(checkHasMinMaxPercents, 'Should contain min and max values') + @tuiDefaultProp( + tuiCheckKeyStepsHaveMinMaxPercents, + 'Should contain min and max values', + ) keySteps: TuiKeySteps = []; @Output() @@ -65,13 +71,7 @@ export class TuiSliderKeyStepsDirective } get controlValue(): number { - const {valuePercentage} = this.slider; - const [lowerStep, upperStep] = findKeyStepsBoundariesByFn( - this.keySteps, - ([keyStepPercentage, _]) => valuePercentage <= keyStepPercentage, - ); - - return transformToControlValue(valuePercentage, lowerStep, upperStep); + return tuiPercentageToKeyStepValue(this.slider.valuePercentage, this.keySteps); } constructor( @@ -96,31 +96,19 @@ export class TuiSliderKeyStepsDirective return; } - const [lowerStep, upperStep] = findKeyStepsBoundariesByFn( - this.keySteps, - ([_, keyStepValue]) => controlValue <= keyStepValue, - ); - - this.slider.value = this.transformToNativeValue( - controlValue, - lowerStep, - upperStep, - ); + this.slider.value = this.transformToNativeValue(controlValue); } protected getFallbackValue(): number { return 0; } - private transformToNativeValue( - controlValue: number, - [upperStepPercent, upperStepValue]: [number, number], - [lowerStepPercent, lowerStepValue]: [number, number], - ): number { + private transformToNativeValue(controlValue: number): number { const {min, max} = this.slider; - const ratio = (controlValue - lowerStepValue) / (upperStepValue - lowerStepValue); - const newValuePercentage = - (upperStepPercent - lowerStepPercent) * ratio + lowerStepPercent; + const newValuePercentage = tuiKeyStepValueToPercentage( + controlValue, + this.keySteps, + ); return (newValuePercentage * (max - min)) / 100 + min; } @@ -135,38 +123,7 @@ export class TuiSliderKeyStepsDirective export class TuiSliderTickLabelPipe implements PipeTransform { transform(tickIndex: number, totalSegments: number, keySteps: TuiKeySteps): number { const percentage = (100 / totalSegments) * tickIndex; - const [lowerStep, upperStep] = findKeyStepsBoundariesByFn( - keySteps, - ([keyStepPercentage, _]) => percentage <= keyStepPercentage, - ); - return transformToControlValue(percentage, upperStep, lowerStep); + return tuiPercentageToKeyStepValue(percentage, keySteps); } } - -function checkHasMinMaxPercents(steps: TuiKeySteps): boolean { - return !steps.length || (steps[0][0] === 0 && steps[steps.length - 1][0] === 100); -} - -function findKeyStepsBoundariesByFn( - keySteps: TuiKeySteps, - fn: ([keyStepPercent, keyStepValue]: [number, number]) => boolean, -): [[number, number], [number, number]] { - const keyStepUpperIndex = keySteps.findIndex((ketStep, i) => i && fn(ketStep)); - const lowerStep = keySteps[keyStepUpperIndex - 1]; - const upperStep = keySteps[keyStepUpperIndex]; - - return [lowerStep, upperStep]; -} - -function transformToControlValue( - valuePercentage: number, - [upperStepPercent, upperStepValue]: [number, number], - [lowerStepPercent, lowerStepValue]: [number, number], -): number { - const ratio = - (valuePercentage - lowerStepPercent) / (upperStepPercent - lowerStepPercent); - const controlValue = (upperStepValue - lowerStepValue) * ratio + lowerStepValue; - - return round(controlValue, TUI_FLOATING_PRECISION); -} diff --git a/projects/kit/utils/index.ts b/projects/kit/utils/index.ts index 56bc229240a1..37c8534c8d7a 100644 --- a/projects/kit/utils/index.ts +++ b/projects/kit/utils/index.ts @@ -2,4 +2,5 @@ export * from '@taiga-ui/kit/utils/dom'; export * from '@taiga-ui/kit/utils/files'; export * from '@taiga-ui/kit/utils/format'; export * from '@taiga-ui/kit/utils/mask'; +export * from '@taiga-ui/kit/utils/math'; export * from '@taiga-ui/kit/utils/miscellaneous'; diff --git a/projects/kit/utils/math/index.ts b/projects/kit/utils/math/index.ts index fa03b71fd0f4..097e162bbbec 100644 --- a/projects/kit/utils/math/index.ts +++ b/projects/kit/utils/math/index.ts @@ -1 +1,2 @@ export * from './horizontal-direction-to-number'; +export * from './key-steps'; diff --git a/projects/kit/utils/math/key-steps.ts b/projects/kit/utils/math/key-steps.ts new file mode 100644 index 000000000000..c1198173802d --- /dev/null +++ b/projects/kit/utils/math/key-steps.ts @@ -0,0 +1,48 @@ +import {round} from '@taiga-ui/cdk'; +import {TUI_FLOATING_PRECISION} from '@taiga-ui/kit/constants'; +import {TuiKeySteps} from '@taiga-ui/kit/types'; + +function tuiFindKeyStepsBoundariesByFn( + keySteps: TuiKeySteps, + fn: ([keyStepPercent, keyStepValue]: [number, number]) => boolean, +): [[number, number], [number, number]] { + const keyStepUpperIndex = keySteps.findIndex((keyStep, i) => i && fn(keyStep)); + const lowerStep = keySteps[keyStepUpperIndex - 1]; + const upperStep = keySteps[keyStepUpperIndex]; + + return [lowerStep, upperStep]; +} + +export function tuiPercentageToKeyStepValue( + valuePercentage: number, + keySteps: TuiKeySteps, +): number { + const [[lowerStepPercent, lowerStepValue], [upperStepPercent, upperStepValue]] = + tuiFindKeyStepsBoundariesByFn( + keySteps, + ([keyStepPercentage, _]) => valuePercentage <= keyStepPercentage, + ); + const ratio = + (valuePercentage - lowerStepPercent) / (upperStepPercent - lowerStepPercent); + const controlValue = (upperStepValue - lowerStepValue) * ratio + lowerStepValue; + + return round(controlValue, TUI_FLOATING_PRECISION); +} + +export function tuiKeyStepValueToPercentage( + value: number, + keySteps: TuiKeySteps, +): number { + const [[lowerStepPercent, lowerStepValue], [upperStepPercent, upperStepValue]] = + tuiFindKeyStepsBoundariesByFn( + keySteps, + ([_, keyStepValue]) => value <= keyStepValue, + ); + const ratio = (value - lowerStepValue) / (upperStepValue - lowerStepValue); + + return (upperStepPercent - lowerStepPercent) * ratio + lowerStepPercent; +} + +export function tuiCheckKeyStepsHaveMinMaxPercents(steps: TuiKeySteps): boolean { + return !steps.length || (steps[0][0] === 0 && steps[steps.length - 1][0] === 100); +} diff --git a/projects/kit/utils/math/test/key-steps.spec.ts b/projects/kit/utils/math/test/key-steps.spec.ts new file mode 100644 index 000000000000..5183af58d528 --- /dev/null +++ b/projects/kit/utils/math/test/key-steps.spec.ts @@ -0,0 +1,42 @@ +import { + TuiKeySteps, + tuiKeyStepValueToPercentage, + tuiPercentageToKeyStepValue, +} from '@taiga-ui/kit'; + +describe('KeySteps utils', () => { + const keySteps: TuiKeySteps = [ + [0, -100], + [50, 0], + [100, 10], + ]; + + const testContexts = [ + {percentage: 0, value: -100}, + {percentage: 5, value: -90}, + {percentage: 10, value: -80}, + {percentage: 33, value: -34}, + {percentage: 49, value: -2}, + {percentage: 50, value: 0}, + {percentage: 60, value: 2}, + {percentage: 75, value: 5}, + {percentage: 90, value: 8}, + {percentage: 100, value: 10}, + ] as const; + + describe('tuiPercentageToKeyStepValue', () => { + for (const {percentage, value} of testContexts) { + it(`${percentage}% => ${value}`, () => { + expect(tuiPercentageToKeyStepValue(percentage, keySteps)).toBe(value); + }); + } + }); + + describe('tuiKeyStepValueToPercentage', () => { + for (const {value, percentage} of testContexts) { + it(`${value} => ${percentage}%`, () => { + expect(tuiKeyStepValueToPercentage(value, keySteps)).toBe(percentage); + }); + } + }); +});