From 245c8d59a1fbdfbf3a9a97e66ca1c44bda8ea8b3 Mon Sep 17 00:00:00 2001 From: Barsukov Nikita Date: Tue, 5 Apr 2022 15:39:52 +0300 Subject: [PATCH] feat(kit): `Range` use `Slider` inside (#1538) * refactor(kit): `Range` use `Slider` inside * fix(kit): `Slider` improve directive `readonly` * chore(demo): `Range` extend example with ticks labels * chore(demo): `Range` extend example with ticks labels * fix(kit): `Range` prevent cursor's blinking during dragging of the thumb * chore(kit): `Range` review comments * chore(kit): `Slider` replace Array by Set * chore(kit): `Range` add not-chromium Edge support (16-18) Co-authored-by: nsbarsukov --- projects/core/styles/mixins/slider.less | 16 ++ .../cypress/support/shared.entities.ts | 1 + .../cypress/tests/kit/range/range.spec.ts | 239 ++++++++++++++++++ .../components/range/examples/1/index.html | 1 + .../components/range/examples/2/index.html | 37 +++ .../components/range/examples/2/index.less | 21 ++ .../components/range/examples/2/index.ts | 17 ++ .../components/range/examples/3/index.html | 31 +++ .../components/range/examples/3/index.less | 18 ++ .../components/range/examples/3/index.ts | 25 ++ .../components/range/examples/4/index.html | 22 ++ .../components/range/examples/4/index.less | 18 ++ .../components/range/examples/4/index.ts | 26 ++ .../{declare-form.txt => declare-form.md} | 12 +- .../range/examples/import/import-module.md | 16 ++ .../range/examples/import/import-module.txt | 13 - .../range/examples/import/insert-template.md | 11 + .../range/examples/import/insert-template.txt | 5 - .../components/range/range.component.ts | 51 ++-- .../modules/components/range/range.module.ts | 18 +- .../components/range/range.template.html | 132 ++++++++-- .../slider/slider.common.template.html | 16 +- projects/kit/abstract/slider/slider.ts | 45 ++-- .../input-range/input-range.style.less | 4 +- projects/kit/components/range/index.ts | 1 + .../range/range-change.directive.ts | 105 ++++++++ .../kit/components/range/range.component.ts | 149 ++++++++--- projects/kit/components/range/range.module.ts | 11 +- .../kit/components/range/range.style.less | 173 +++++++++++++ .../kit/components/range/range.template.html | 57 +++++ .../range/test/range.component.spec.ts | 69 ++--- projects/kit/components/slider/index.ts | 1 + .../components/slider/slider-old.component.ts | 16 +- .../slider/slider-readonly.directive.ts | 41 +++ .../kit/components/slider/slider.module.ts | 3 + .../kit/components/slider/slider.style.less | 2 + 36 files changed, 1238 insertions(+), 185 deletions(-) create mode 100644 projects/demo-integrations/cypress/tests/kit/range/range.spec.ts create mode 100644 projects/demo/src/modules/components/range/examples/2/index.html create mode 100644 projects/demo/src/modules/components/range/examples/2/index.less create mode 100644 projects/demo/src/modules/components/range/examples/2/index.ts create mode 100644 projects/demo/src/modules/components/range/examples/3/index.html create mode 100644 projects/demo/src/modules/components/range/examples/3/index.less create mode 100644 projects/demo/src/modules/components/range/examples/3/index.ts create mode 100644 projects/demo/src/modules/components/range/examples/4/index.html create mode 100644 projects/demo/src/modules/components/range/examples/4/index.less create mode 100644 projects/demo/src/modules/components/range/examples/4/index.ts rename projects/demo/src/modules/components/range/examples/import/{declare-form.txt => declare-form.md} (51%) create mode 100644 projects/demo/src/modules/components/range/examples/import/import-module.md delete mode 100644 projects/demo/src/modules/components/range/examples/import/import-module.txt create mode 100644 projects/demo/src/modules/components/range/examples/import/insert-template.md delete mode 100644 projects/demo/src/modules/components/range/examples/import/insert-template.txt create mode 100644 projects/kit/components/range/range-change.directive.ts create mode 100644 projects/kit/components/range/range.style.less create mode 100644 projects/kit/components/range/range.template.html create mode 100644 projects/kit/components/slider/slider-readonly.directive.ts diff --git a/projects/core/styles/mixins/slider.less b/projects/core/styles/mixins/slider.less index 9622572d0423..e3967e475d6f 100644 --- a/projects/core/styles/mixins/slider.less +++ b/projects/core/styles/mixins/slider.less @@ -30,4 +30,20 @@ tui-input-slider + & { margin-left: calc(var(--tui-radius-m) / 2 + @first-tick-center); } + + tui-range + & { + @thumb: @thumb-diameters[ @@input-size]; + margin-left: @thumb; + margin-right: @thumb; + + & > * { + &:first-child { + left: -@thumb; + } + + &:last-child { + right: -@thumb; + } + } + } } diff --git a/projects/demo-integrations/cypress/support/shared.entities.ts b/projects/demo-integrations/cypress/support/shared.entities.ts index cbb054f5ae37..0d6c155fda74 100644 --- a/projects/demo-integrations/cypress/support/shared.entities.ts +++ b/projects/demo-integrations/cypress/support/shared.entities.ts @@ -10,3 +10,4 @@ export const SELECT_PAGE_URL = 'components/select'; export const SLIDER_PAGE_URL = 'components/slider'; export const INPUT_SLIDER_PAGE_URL = 'components/input-slider'; export const INPUT_PAGE_URL = 'components/input'; +export const RANGE_PAGE_URL = 'components/range'; diff --git a/projects/demo-integrations/cypress/tests/kit/range/range.spec.ts b/projects/demo-integrations/cypress/tests/kit/range/range.spec.ts new file mode 100644 index 000000000000..79bb62a08b53 --- /dev/null +++ b/projects/demo-integrations/cypress/tests/kit/range/range.spec.ts @@ -0,0 +1,239 @@ +import {RANGE_PAGE_URL} from '../../../support/shared.entities'; + +const initializeBaseAliases = (exampleId: string) => { + cy.get(`${exampleId} tui-range`).should('be.visible').as('range'); + cy.get(`${exampleId} tui-range input[type=range]:first-child`) + .should('be.visible') + .as('leftSlider'); + cy.get(`${exampleId} tui-range input[type=range]:last-child`) + .should('be.visible') + .as('rightSlider'); +}; + +describe('Range', () => { + beforeEach(() => { + cy.viewport('macbook-13'); + }); + + describe('examples page', () => { + beforeEach(() => { + cy.goToDemoPage(RANGE_PAGE_URL); + cy.hideHeader(); + cy.hideNavigation(); + }); + + describe('change selected range on click', () => { + const EXAMPLE_ID = '#base'; + + beforeEach(() => { + initializeBaseAliases(EXAMPLE_ID); + + cy.get(EXAMPLE_ID).scrollIntoView(); + }); + + it('click on the beginning of the track changes only nearest (left) slider', () => { + cy.get('@range').click('left'); + cy.get('@leftSlider').should('have.value', 0); + cy.get('@rightSlider').should('have.value', 6); + + cy.get(EXAMPLE_ID).matchImageSnapshot('01-range-click-checks-0-6'); + }); + + it('click on the end of the track changes only nearest (right) slider', () => { + cy.get('@range').click('right'); + cy.get('@leftSlider').should('have.value', 4); + cy.get('@rightSlider').should('have.value', 10); + + cy.get(EXAMPLE_ID).matchImageSnapshot('02-range-click-checks-4-10'); + }); + + it('click between two thumbs triggers only nearest thumb', () => { + cy.get('@range').click(100, 0); + cy.get('@range').click('right'); + + cy.get('@leftSlider').should('have.value', 1); + cy.get('@rightSlider').should('have.value', 10); + cy.get(EXAMPLE_ID).matchImageSnapshot('03-range-click-checks-1-10'); + + cy.get('@range').click('center'); + cy.get('@leftSlider').should('have.value', 5); + cy.get('@rightSlider').should('have.value', 10); + cy.get(EXAMPLE_ID).matchImageSnapshot('03-range-click-checks-5-10'); + }); + }); + + describe('keyboard interactions', () => { + describe('basic range (from 0 to 1000 with 250 steps). Initial value [0, 250]', () => { + const EXAMPLE_ID = '#segments'; + + beforeEach(() => { + initializeBaseAliases(EXAMPLE_ID); + + cy.get(EXAMPLE_ID).scrollIntoView(); + }); + + const checkValuesAfterPressing = ( + key: string, + [leftSliderValue, rightSliderValue]: [number, number], + ) => { + cy.get('body').type(`{${key}}`); + cy.get('@leftSlider').should('have.value', leftSliderValue); + cy.get('@rightSlider').should('have.value', rightSliderValue); + cy.get(`${EXAMPLE_ID} output`) + .invoke('text') + .should( + 'match', + new RegExp( + `\\s${leftSliderValue},.+${rightSliderValue}\\s`, + 'gs', + ), + ); + }; + + it('pressing of Arrow Right increases by one step (after focus on right slider)', () => { + cy.get('@leftSlider').should('have.value', 0); + cy.get('@rightSlider').should('have.value', 250); + cy.get('@rightSlider').focus(); + + checkValuesAfterPressing('rightarrow', [0, 500]); + checkValuesAfterPressing('rightarrow', [0, 750]); + checkValuesAfterPressing('rightarrow', [0, 1000]); + }); + + it('pressing of Arrow Right increases by one step (after focus on left slider)', () => { + cy.get('@range').click('right'); + cy.get('@leftSlider').should('have.value', 0); + cy.get('@rightSlider').should('have.value', 1000); + cy.get('@leftSlider').focus(); + + checkValuesAfterPressing('rightarrow', [250, 1000]); + checkValuesAfterPressing('rightarrow', [500, 1000]); + checkValuesAfterPressing('rightarrow', [750, 1000]); + checkValuesAfterPressing('rightarrow', [1000, 1000]); + }); + + it('pressing of Arrow Left decreases by one step (after setting right thumb active via click)', () => { + cy.get('@range').click('right'); + cy.get('@leftSlider').should('have.value', 0); + cy.get('@rightSlider').should('have.value', 1000); + + checkValuesAfterPressing('leftarrow', [0, 750]); + checkValuesAfterPressing('leftarrow', [0, 500]); + checkValuesAfterPressing('leftarrow', [0, 250]); + checkValuesAfterPressing('leftarrow', [0, 0]); + }); + + it('cannot set left thumb more than right thumb (by Arrow Right)', () => { + cy.get('@leftSlider').should('have.value', 0); + cy.get('@rightSlider').should('have.value', 250); + cy.get('@leftSlider').focus(); + + checkValuesAfterPressing('rightarrow', [250, 250]); + checkValuesAfterPressing('rightarrow', [250, 250]); + checkValuesAfterPressing('rightarrow', [250, 250]); + }); + + it('cannot set right thumb less than left thumb (by ArrowLeft)', () => { + cy.get('@range').click('right'); + cy.get('@leftSlider').focus(); + + cy.get('body').type('{rightarrow}{rightarrow}'); + cy.get('@leftSlider').should('have.value', 500); + cy.get('@rightSlider').should('have.value', 1000); + + cy.get('@rightSlider').focus(); + cy.get('body').type( + '{leftarrow}{leftarrow}{leftarrow}{leftarrow}{leftarrow}', + ); + + cy.get('@leftSlider').should('have.value', 500); + cy.get('@rightSlider').should('have.value', 500); + + cy.get('@rightSlider').focus(); + cy.get('body').type( + '{leftarrow}{leftarrow}{leftarrow}{leftarrow}{leftarrow}', + ); + + cy.get('@leftSlider').focus(); + cy.get('body').type( + '{rightarrow}{rightarrow}{rightarrow}{rightarrow}{rightarrow}', + ); + + cy.get('@leftSlider').should('have.value', 500); + cy.get('@rightSlider').should('have.value', 500); + }); + }); + + describe('range with keySteps (from 0 to 1M) with 8 steps. Initial value [0, 100_000]', () => { + const EXAMPLE_ID = '#key-steps'; + + beforeEach(() => { + initializeBaseAliases(EXAMPLE_ID); + + cy.get(EXAMPLE_ID).scrollIntoView(); + }); + + const checkValuesAfterPressing = ( + key: string, + [leftSliderValue, rightSliderValue]: [number, number], + ) => { + cy.get('body').type(`{${key}}`); + cy.get(`${EXAMPLE_ID} output`) + .invoke('text') + .should( + 'match', + new RegExp( + `\\s${leftSliderValue},.+${rightSliderValue}\\s`, + 'gs', + ), + ); + }; + + it('ArrowUp increases value of the focused slider', () => { + cy.get('@rightSlider').focus(); + + checkValuesAfterPressing('uparrow', [0, 300_000]); + checkValuesAfterPressing('uparrow', [0, 500_000]); + checkValuesAfterPressing('uparrow', [0, 750_000]); + checkValuesAfterPressing('uparrow', [0, 1_000_000]); + + cy.get('@leftSlider').focus(); + + checkValuesAfterPressing('uparrow', [5_000, 1_000_000]); + checkValuesAfterPressing('uparrow', [10_000, 1_000_000]); + checkValuesAfterPressing('uparrow', [55_000, 1_000_000]); + checkValuesAfterPressing('uparrow', [100_000, 1_000_000]); + }); + + it('ArrowDown decreases value of the focused slider', () => { + cy.get('@rightSlider').focus(); + + checkValuesAfterPressing('downarrow', [0, 55_000]); + checkValuesAfterPressing('downarrow', [0, 10_000]); + checkValuesAfterPressing('downarrow', [0, 5_000]); + }); + + it('cannot set position of the LEFT slider MORE THAN position of the RIGHT slider (by ArrowUp)', () => { + cy.get('@leftSlider').focus(); + cy.get('body').type('{uparrow}{uparrow}{uparrow}{uparrow}'); + + checkValuesAfterPressing('uparrow', [100_000, 100_000]); + checkValuesAfterPressing('uparrow', [100_000, 100_000]); + checkValuesAfterPressing('uparrow', [100_000, 100_000]); + }); + + it('cannot set position of the RIGHT slider LESS THAN position of the LEFT slider (by ArrowDown)', () => { + cy.get('@leftSlider').focus(); + cy.get('body').type('{uparrow}'); + + cy.get('@rightSlider').focus(); + cy.get('body').type('{downarrow}{downarrow}{downarrow}'); + + checkValuesAfterPressing('downarrow', [5_000, 5_000]); + checkValuesAfterPressing('downarrow', [5_000, 5_000]); + checkValuesAfterPressing('downarrow', [5_000, 5_000]); + }); + }); + }); + }); +}); diff --git a/projects/demo/src/modules/components/range/examples/1/index.html b/projects/demo/src/modules/components/range/examples/1/index.html index 55d1620ae6fd..7a466a0a5488 100644 --- a/projects/demo/src/modules/components/range/examples/1/index.html +++ b/projects/demo/src/modules/components/range/examples/1/index.html @@ -1,4 +1,5 @@ diff --git a/projects/demo/src/modules/components/range/examples/2/index.html b/projects/demo/src/modules/components/range/examples/2/index.html new file mode 100644 index 000000000000..338a2c470b37 --- /dev/null +++ b/projects/demo/src/modules/components/range/examples/2/index.html @@ -0,0 +1,37 @@ + +

S-size

+ + + +

+ Control value: + + {{smallRangeValue | json}} + +

+
+ + +

M-size

+ + + +

+ Control value: + + {{bigRangeControl.value | json}} + +

+
diff --git a/projects/demo/src/modules/components/range/examples/2/index.less b/projects/demo/src/modules/components/range/examples/2/index.less new file mode 100644 index 000000000000..bcbed57e19e7 --- /dev/null +++ b/projects/demo/src/modules/components/range/examples/2/index.less @@ -0,0 +1,21 @@ +@import 'taiga-ui-local'; + +:host { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + row-gap: 1rem; +} + +.island { + box-sizing: border-box; + width: 49%; + + @media @mobile { + width: 100%; + } +} + +.range { + margin: 2rem 0; +} diff --git a/projects/demo/src/modules/components/range/examples/2/index.ts b/projects/demo/src/modules/components/range/examples/2/index.ts new file mode 100644 index 000000000000..f9da6026e1fb --- /dev/null +++ b/projects/demo/src/modules/components/range/examples/2/index.ts @@ -0,0 +1,17 @@ +import {Component} from '@angular/core'; +import {FormControl} from '@angular/forms'; +import {changeDetection} from '@demo/emulate/change-detection'; +import {encapsulation} from '@demo/emulate/encapsulation'; + +@Component({ + selector: 'tui-range-example-2', + templateUrl: './index.html', + styleUrls: ['./index.less'], + changeDetection, + encapsulation, +}) +export class TuiRangeExample2 { + smallRangeValue = [0, 40]; + + readonly bigRangeControl = new FormControl([40, 60]); +} diff --git a/projects/demo/src/modules/components/range/examples/3/index.html b/projects/demo/src/modules/components/range/examples/3/index.html new file mode 100644 index 000000000000..7fa162d02f3f --- /dev/null +++ b/projects/demo/src/modules/components/range/examples/3/index.html @@ -0,0 +1,31 @@ + + +
+
+ + {{label | i18nPlural: pluralMap}} + +
+ + + +
3/4
+
+
+ +

+ Control value: + + {{value | json}} + +

diff --git a/projects/demo/src/modules/components/range/examples/3/index.less b/projects/demo/src/modules/components/range/examples/3/index.less new file mode 100644 index 000000000000..4c6867ac9b57 --- /dev/null +++ b/projects/demo/src/modules/components/range/examples/3/index.less @@ -0,0 +1,18 @@ +@import 'taiga-ui-local'; + +.range { + z-index: 1; + + /* (Optionally) expand clickable area as you wish */ + &:after { + content: ''; + position: absolute; + top: -0.5rem; + bottom: -1.5rem; + width: 100%; + } +} + +.ticks-labels { + .tui-slider-ticks-labels(m); +} diff --git a/projects/demo/src/modules/components/range/examples/3/index.ts b/projects/demo/src/modules/components/range/examples/3/index.ts new file mode 100644 index 000000000000..a4fcf41525da --- /dev/null +++ b/projects/demo/src/modules/components/range/examples/3/index.ts @@ -0,0 +1,25 @@ +import {Component} from '@angular/core'; +import {changeDetection} from '@demo/emulate/change-detection'; +import {encapsulation} from '@demo/emulate/encapsulation'; + +@Component({ + selector: 'tui-range-example-3', + templateUrl: './index.html', + styleUrls: ['./index.less'], + changeDetection, + encapsulation, +}) +export class TuiRangeExample3 { + readonly min = 0; + readonly max = 1000; + readonly quantum = 250; + readonly segments = 4; + readonly labels = [...new Array(this.segments + 1).keys()].map( + i => this.min + this.quantum * i, + ); + + value = [0, 250]; + + // https://angular.io/api/common/I18nPluralPipe + pluralMap = {'=0': '0', '=1': '# item', '=1000': 'MAX', other: '# items'}; +} diff --git a/projects/demo/src/modules/components/range/examples/4/index.html b/projects/demo/src/modules/components/range/examples/4/index.html new file mode 100644 index 000000000000..7f0d664bfaa3 --- /dev/null +++ b/projects/demo/src/modules/components/range/examples/4/index.html @@ -0,0 +1,22 @@ + + +
+ {{label}} +
+ +

+ Control value: + + {{value | json}} + +

diff --git a/projects/demo/src/modules/components/range/examples/4/index.less b/projects/demo/src/modules/components/range/examples/4/index.less new file mode 100644 index 000000000000..4c6867ac9b57 --- /dev/null +++ b/projects/demo/src/modules/components/range/examples/4/index.less @@ -0,0 +1,18 @@ +@import 'taiga-ui-local'; + +.range { + z-index: 1; + + /* (Optionally) expand clickable area as you wish */ + &:after { + content: ''; + position: absolute; + top: -0.5rem; + bottom: -1.5rem; + width: 100%; + } +} + +.ticks-labels { + .tui-slider-ticks-labels(m); +} diff --git a/projects/demo/src/modules/components/range/examples/4/index.ts b/projects/demo/src/modules/components/range/examples/4/index.ts new file mode 100644 index 000000000000..005dc974d4c9 --- /dev/null +++ b/projects/demo/src/modules/components/range/examples/4/index.ts @@ -0,0 +1,26 @@ +import {Component} from '@angular/core'; +import {changeDetection} from '@demo/emulate/change-detection'; +import {encapsulation} from '@demo/emulate/encapsulation'; +import {TuiKeySteps} from '@taiga-ui/kit'; + +@Component({ + selector: 'tui-range-example-4', + templateUrl: './index.html', + styleUrls: ['./index.less'], + changeDetection, + encapsulation, +}) +export class TuiRangeExample4 { + readonly max = 1_000_000; + readonly ticksLabels = ['0', '10K', '100K', '500k', '1000K']; + readonly segments = this.ticksLabels.length - 1; + + value = [0, 100_000]; + + readonly keySteps: TuiKeySteps = [ + // [percent, value] + [25, 10_000], + [50, 100_000], + [75, 500_000], + ]; +} diff --git a/projects/demo/src/modules/components/range/examples/import/declare-form.txt b/projects/demo/src/modules/components/range/examples/import/declare-form.md similarity index 51% rename from projects/demo/src/modules/components/range/examples/import/declare-form.txt rename to projects/demo/src/modules/components/range/examples/import/declare-form.md index af74213b4797..6c2b1dbc4d8d 100644 --- a/projects/demo/src/modules/components/range/examples/import/declare-form.txt +++ b/projects/demo/src/modules/components/range/examples/import/declare-form.md @@ -1,12 +1,14 @@ +```ts import {FormControl, FormGroup} from '@angular/forms'; -... +// ... @Component({ - ... + // ... }) export class MyComponent { - testForm = new FormGroup({ - testValue: new FormControl(0) - }); + testForm = new FormGroup({ + testValue: new FormControl(0), + }); } +``` diff --git a/projects/demo/src/modules/components/range/examples/import/import-module.md b/projects/demo/src/modules/components/range/examples/import/import-module.md new file mode 100644 index 000000000000..59336a05ad18 --- /dev/null +++ b/projects/demo/src/modules/components/range/examples/import/import-module.md @@ -0,0 +1,16 @@ +```ts +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {TuiRangeModule} from '@taiga-ui/kit'; + +// ... + +@NgModule({ + imports: [ + // ... + FormsModule, + ReactiveFormsModule, + TuiRangeModule, + ], +}) +class ExampleModule {} +``` diff --git a/projects/demo/src/modules/components/range/examples/import/import-module.txt b/projects/demo/src/modules/components/range/examples/import/import-module.txt deleted file mode 100644 index 81e1e1432b47..000000000000 --- a/projects/demo/src/modules/components/range/examples/import/import-module.txt +++ /dev/null @@ -1,13 +0,0 @@ -import {FormsModule, ReactiveFormsModule} from '@angular/forms'; -import {TuiSliderModule} from '@taiga-ui/kit'; - -... - -@NgModule({ - imports: [ - ... - FormsModule, - ReactiveFormsModule, - TuiRangeModule - ], -... diff --git a/projects/demo/src/modules/components/range/examples/import/insert-template.md b/projects/demo/src/modules/components/range/examples/import/insert-template.md new file mode 100644 index 000000000000..50f02b228fd3 --- /dev/null +++ b/projects/demo/src/modules/components/range/examples/import/insert-template.md @@ -0,0 +1,11 @@ +```html + + + +``` diff --git a/projects/demo/src/modules/components/range/examples/import/insert-template.txt b/projects/demo/src/modules/components/range/examples/import/insert-template.txt deleted file mode 100644 index 40f6fd284a69..000000000000 --- a/projects/demo/src/modules/components/range/examples/import/insert-template.txt +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/projects/demo/src/modules/components/range/range.component.ts b/projects/demo/src/modules/components/range/range.component.ts index 2e1d148c0a88..8abe412678df 100644 --- a/projects/demo/src/modules/components/range/range.component.ts +++ b/projects/demo/src/modules/components/range/range.component.ts @@ -1,30 +1,41 @@ import {Component} from '@angular/core'; import {FormControl} from '@angular/forms'; import {changeDetection} from '@demo/emulate/change-detection'; +import {TuiDocExample} from '@taiga-ui/addon-doc'; import {TuiPluralize, TuiSizeS} from '@taiga-ui/core'; import {TuiKeySteps} from '@taiga-ui/kit'; -import {default as example1Html} from '!!raw-loader!./examples/1/index.html'; -import {default as example1Ts} from '!!raw-loader!./examples/1/index.ts'; -import {default as exampleForm} from '!!raw-loader!./examples/import/declare-form.txt'; -import {default as exampleModule} from '!!raw-loader!./examples/import/import-module.txt'; -import {default as exampleHtml} from '!!raw-loader!./examples/import/insert-template.txt'; - -import {FrontEndExample} from '../../interfaces/front-end-example'; - @Component({ selector: 'example-range', templateUrl: './range.template.html', changeDetection, }) export class ExampleTuiRangeComponent { - readonly exampleModule = exampleModule; - readonly exampleHtml = exampleHtml; - readonly exampleForm = exampleForm; + readonly exampleModule = import('!!raw-loader!./examples/import/import-module.md'); + readonly exampleHtml = import('!!raw-loader!./examples/import/insert-template.md'); + readonly exampleForm = import('!!raw-loader!./examples/import/declare-form.md'); + + readonly example1: TuiDocExample = { + HTML: import('!!raw-loader!./examples/1/index.html'), + TypeScript: import('!!raw-loader!./examples/1/index'), + }; + + readonly example2: TuiDocExample = { + HTML: import('!!raw-loader!./examples/2/index.html'), + TypeScript: import('!!raw-loader!./examples/2/index'), + LESS: import('!!raw-loader!./examples/2/index.less'), + }; - readonly example1: FrontEndExample = { - TypeScript: example1Ts, - HTML: example1Html, + readonly example3: TuiDocExample = { + HTML: import('!!raw-loader!./examples/3/index.html'), + LESS: import('!!raw-loader!./examples/3/index.less'), + TypeScript: import('!!raw-loader!./examples/3/index'), + }; + + readonly example4: TuiDocExample = { + HTML: import('!!raw-loader!./examples/4/index.html'), + TypeScript: import('!!raw-loader!./examples/4/index'), + LESS: import('!!raw-loader!./examples/4/index.less'), }; readonly control = new FormControl([0, 0]); @@ -47,17 +58,11 @@ export class ExampleTuiRangeComponent { size: TuiSizeS = this.sizeVariants[1]; - readonly minVariants: readonly number[] = [0, 1, 5, 7.77]; - - min = this.minVariants[0]; - - readonly maxVariants: readonly number[] = [10, 100, 10000]; - - max = this.maxVariants[0]; + min = 0; - readonly segmentsVariants: readonly number[] = [0, 1, 5, 13]; + max = 100; - segments = this.segmentsVariants[0]; + segments = 0; readonly stepsVariants: readonly number[] = [0, 4, 10]; diff --git a/projects/demo/src/modules/components/range/range.module.ts b/projects/demo/src/modules/components/range/range.module.ts index dc1ae329086f..ec01b3aac043 100644 --- a/projects/demo/src/modules/components/range/range.module.ts +++ b/projects/demo/src/modules/components/range/range.module.ts @@ -3,10 +3,13 @@ import {NgModule} from '@angular/core'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import {RouterModule} from '@angular/router'; import {generateRoutes, TuiAddonDocModule} from '@taiga-ui/addon-doc'; -import {TuiLinkModule} from '@taiga-ui/core'; -import {TuiRadioListModule, TuiRangeModule} from '@taiga-ui/kit'; +import {TuiLinkModule, TuiNotificationModule, TuiSvgModule} from '@taiga-ui/core'; +import {TuiIslandModule, TuiRadioListModule, TuiRangeModule} from '@taiga-ui/kit'; import {TuiRangeExample1} from './examples/1'; +import {TuiRangeExample2} from './examples/2'; +import {TuiRangeExample3} from './examples/3'; +import {TuiRangeExample4} from './examples/4'; import {ExampleTuiRangeComponent} from './range.component'; @NgModule({ @@ -18,9 +21,18 @@ import {ExampleTuiRangeComponent} from './range.component'; ReactiveFormsModule, TuiLinkModule, TuiAddonDocModule, + TuiIslandModule, + TuiNotificationModule, + TuiSvgModule, RouterModule.forChild(generateRoutes(ExampleTuiRangeComponent)), ], - declarations: [ExampleTuiRangeComponent, TuiRangeExample1], + declarations: [ + ExampleTuiRangeComponent, + TuiRangeExample1, + TuiRangeExample2, + TuiRangeExample3, + TuiRangeExample4, + ], exports: [ExampleTuiRangeComponent], }) export class ExampleTuiRangeModule {} diff --git a/projects/demo/src/modules/components/range/range.template.html b/projects/demo/src/modules/components/range/range.template.html index 0736781e1bc6..8ef2edaf641d 100644 --- a/projects/demo/src/modules/components/range/range.template.html +++ b/projects/demo/src/modules/components/range/range.template.html @@ -6,6 +6,31 @@

Component allows to choose a part of a range

+ +

+ This component is being refactored. + Soon (next major release) you will see the fresh + version of it! +

+

+ Of course, we keep backward compatibility in mind (for 2.x.x). + You can still use old version of + Range + . +

+ +

+ However, if you are going to use this component, we recommend to + use new version. To enable the "new version"-mode, add + new + directive. Example:  + <tui‑range new ...> +

+
+ + + + + + + + +

+ Use mixin + tui-slider-ticks-labels + to arrange ticks' labels (it places them strictly below + ticks). +

+

+ The mixin accepts only a single argument – size of the + slider ( + m + or + s + ). +

+
+ +
+ + + +
@@ -48,7 +119,6 @@ documentationPropertyName="min" documentationPropertyMode="input" documentationPropertyType="number" - [documentationPropertyValues]="minVariants" [(documentationPropertyValue)]="min" > Min value @@ -58,48 +128,38 @@ documentationPropertyName="max" documentationPropertyMode="input" documentationPropertyType="number" - [documentationPropertyValues]="maxVariants" [(documentationPropertyValue)]="max" > Max value - Plural forms for labels. TuiPluralize array is deprecated. Use - object that mimics the - - ICU format - - for i18nPlural + Number of actual discrete slider steps - A number of visual segments + Quantum - Number of actual discrete slider steps + A number of visual segments - Quantum + Plural forms for labels. TuiPluralize array is deprecated. Use + object that mimics the + + ICU format + + for i18nPlural + +

+ + See examples how create labels for ticks without this + property (outside of the component). + +

diff --git a/projects/kit/abstract/slider/slider.common.template.html b/projects/kit/abstract/slider/slider.common.template.html index d69d0d75b6fc..3129c17bce8a 100644 --- a/projects/kit/abstract/slider/slider.common.template.html +++ b/projects/kit/abstract/slider/slider.common.template.html @@ -40,10 +40,10 @@ [class.dot_focus-visible]="focusVisibleLeft && computedFocused" [tuiFocusable]="isLeftFocusable" (tuiFocusVisibleChange)="onLeftFocusVisible($event)" - (keydown.arrowLeft.prevent)="decrement(false)" - (keydown.arrowDown.prevent)="decrement(false)" - (keydown.arrowRight.prevent)="increment(false)" - (keydown.arrowUp.prevent)="increment(false)" + (keydown.arrowLeft.prevent)="decrement()" + (keydown.arrowDown.prevent)="decrement()" + (keydown.arrowRight.prevent)="increment()" + (keydown.arrowUp.prevent)="increment()" >
diff --git a/projects/kit/abstract/slider/slider.ts b/projects/kit/abstract/slider/slider.ts index daa92d5ec359..c95ee1a73e75 100644 --- a/projects/kit/abstract/slider/slider.ts +++ b/projects/kit/abstract/slider/slider.ts @@ -53,10 +53,10 @@ export abstract class AbstractTuiSlider TuiEventWith >(); - @ViewChild('dotLeft') + @ViewChild('dotLeft', {read: ElementRef}) protected dotLeft?: ElementRef; - @ViewChild('dotRight') + @ViewChild('dotRight', {read: ElementRef}) protected dotRight?: ElementRef; @Input() @@ -144,8 +144,7 @@ export abstract class AbstractTuiSlider abstract get left(): number; abstract get right(): number; - protected abstract processValue(value: number, right?: boolean): void; - protected abstract processStep(increment: boolean, right?: boolean): void; + abstract processValue(value: number, right?: boolean): void; ngOnInit() { super.ngOnInit(); @@ -244,14 +243,6 @@ export abstract class AbstractTuiSlider this.pointerDown$.next(event); } - decrement(right: boolean) { - this.processStep(false, right); - } - - increment(right: boolean) { - this.processStep(true, right); - } - getSegmentLabel(segment: number): number { return round(this.getValueFromFraction(segment / this.segments), 2); } @@ -280,15 +271,7 @@ export abstract class AbstractTuiSlider this.focusVisibleRight = focusVisible; } - protected getFractionFromValue(value: number): number { - const fraction = (value - this.min) / this.length; - - return this.keySteps !== null - ? this.fractionValueKeyStepConverter(value, false) - : clamp(Number.isFinite(fraction) ? fraction : 1, 0, 1); - } - - protected getValueFromFraction(fraction: number): number { + getValueFromFraction(fraction: number): number { return this.keySteps !== null ? this.fractionValueKeyStepConverter(fraction, true) : round( @@ -297,6 +280,20 @@ export abstract class AbstractTuiSlider ); } + fractionGuard(fraction: number): number { + return this.discrete + ? clamp(quantize(fraction, 1 / this.steps), 0, 1) + : clamp(fraction, 0, 1); + } + + protected getFractionFromValue(value: number): number { + const fraction = (value - this.min) / this.length; + + return this.keySteps !== null + ? this.fractionValueKeyStepConverter(value, false) + : clamp(Number.isFinite(fraction) ? fraction : 1, 0, 1); + } + protected getCalibratedFractionFromEvents( rect: ClientRect, clientX: number, @@ -375,12 +372,6 @@ export abstract class AbstractTuiSlider ) / 100; } - private fractionGuard(fraction: number): number { - return this.discrete - ? clamp(quantize(fraction, 1 / this.steps), 0, 1) - : clamp(fraction, 0, 1); - } - private getFractionFromEvents(rect: ClientRect, clientX: number): number { const value = clientX - rect.left - DOT_WIDTH[this.size] / 2; const total = rect.width - DOT_WIDTH[this.size]; diff --git a/projects/kit/components/input-range/input-range.style.less b/projects/kit/components/input-range/input-range.style.less index b6653e478193..925995861017 100644 --- a/projects/kit/components/input-range/input-range.style.less +++ b/projects/kit/components/input-range/input-range.style.less @@ -149,8 +149,8 @@ z-index: 1; border-top-left-radius: 0; border-top-right-radius: 0; - margin: -0.5625rem 0 0; - color: transparent; + margin: -0.125rem 0 0; + background: transparent; :host._disabled &, :host._readonly & { diff --git a/projects/kit/components/range/index.ts b/projects/kit/components/range/index.ts index 7fef8e11467a..a65bf35fc4aa 100644 --- a/projects/kit/components/range/index.ts +++ b/projects/kit/components/range/index.ts @@ -1,2 +1,3 @@ export * from './range.component'; export * from './range.module'; +export * from './range-change.directive'; diff --git a/projects/kit/components/range/range-change.directive.ts b/projects/kit/components/range/range-change.directive.ts new file mode 100644 index 000000000000..a41e1f0b7bb0 --- /dev/null +++ b/projects/kit/components/range/range-change.directive.ts @@ -0,0 +1,105 @@ +import {DOCUMENT} from '@angular/common'; +import {Directive, ElementRef, Inject} from '@angular/core'; +import {clamp, round, TuiDestroyService, typedFromEvent} from '@taiga-ui/cdk'; +import {TUI_FLOATING_PRECISION} from '@taiga-ui/kit/constants'; +import {merge, Observable} from 'rxjs'; +import {filter, map, repeat, startWith, switchMap, takeUntil, tap} from 'rxjs/operators'; + +import {TuiRangeComponent} from './range.component'; + +// @dynamic +@Directive({ + selector: 'tui-range', + providers: [TuiDestroyService], +}) +export class TuiRangeChangeDirective { + /** + * TODO replace with pointer events (when all supported browsers can handle them). + * Dont forget to use setPointerCapture instead of listening all documentRef events + */ + private readonly pointerDown$ = merge( + typedFromEvent(this.elementRef.nativeElement, 'touchstart', {passive: true}).pipe( + filter(({touches}) => touches.length === 1), + map(({touches}) => touches[0]), + ), + typedFromEvent(this.elementRef.nativeElement, 'mousedown', {passive: true}), + ); + + private readonly pointerMove$ = merge( + typedFromEvent(this.documentRef, 'touchmove').pipe( + filter(({touches}) => touches.length === 1), + map(({touches}) => touches[0]), + ), + typedFromEvent(this.documentRef, 'mousemove'), + ); + + private readonly pointerUp$ = merge( + typedFromEvent(this.documentRef, 'touchend', {passive: true}), + typedFromEvent(this.documentRef, 'mouseup', {passive: true}), + ); + + constructor( + @Inject(DOCUMENT) private readonly documentRef: Document, + @Inject(ElementRef) private readonly elementRef: ElementRef, + @Inject(TuiRangeComponent) private readonly range: TuiRangeComponent, + @Inject(TuiDestroyService) destroy$: Observable, + ) { + let activeThumb: 'left' | 'right'; + + this.pointerDown$ + .pipe( + tap(({clientX, target}) => { + activeThumb = this.detectActiveThumb(clientX, target); + elementRef.nativeElement.focus(); + }), + switchMap(event => this.pointerMove$.pipe(startWith(event))), + map(({clientX}) => clamp(this.getFractionFromEvents(clientX), 0, 1)), + takeUntil(this.pointerUp$), + repeat(), + takeUntil(destroy$), + ) + .subscribe(fraction => { + const value = this.range.getValueFromFraction( + this.range.fractionGuard(fraction), + ); + + this.range.processValue(value, activeThumb === 'right'); + }); + } + + private getFractionFromEvents(clickClientX: number): number { + const hostRect = this.elementRef.nativeElement.getBoundingClientRect(); + const value = clickClientX - hostRect.left; + const total = hostRect.width; + + return round(value / total, TUI_FLOATING_PRECISION); + } + + private detectActiveThumb( + clientX: number, + target: EventTarget | null, + ): 'left' | 'right' { + const [leftSliderRef, rightSliderRef] = this.range.slidersRefs; + + switch (target) { + case leftSliderRef.nativeElement: + return 'left'; + case rightSliderRef.nativeElement: + return 'right'; + default: + return this.findNearestActiveThumb(clientX); + } + } + + private findNearestActiveThumb(clientX: number): 'left' | 'right' { + const fraction = this.getFractionFromEvents(clientX); + const deltaLeft = fraction * 100 - this.range.left; + const deltaRight = fraction * 100 - 100 + this.range.right; + + return Math.abs(deltaLeft) > Math.abs(deltaRight) || + deltaRight > 0 || + (this.range.left === 0 && this.range.right === 100) + ? 'right' + : 'left'; + } +} diff --git a/projects/kit/components/range/range.component.ts b/projects/kit/components/range/range.component.ts index 89cb87f192ec..85137f1492b1 100644 --- a/projects/kit/components/range/range.component.ts +++ b/projects/kit/components/range/range.component.ts @@ -3,31 +3,55 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + Directive, ElementRef, forwardRef, + HostBinding, + HostListener, Inject, + Input, Optional, + QueryList, Self, + ViewChildren, } from '@angular/core'; import {NgControl} from '@angular/forms'; import { + EMPTY_QUERY, isNativeFocusedIn, - round, + nonNegativeFiniteAssertion, TUI_FOCUSABLE_ITEM_ACCESSOR, + tuiDefaultProp, TuiFocusableElementAccessor, TuiNativeFocusableElement, + tuiPure, } from '@taiga-ui/cdk'; -import {AbstractTuiSlider, DOT_WIDTH} from '@taiga-ui/kit/abstract'; -import {TUI_FLOATING_PRECISION} from '@taiga-ui/kit/constants'; +import {AbstractTuiSlider} from '@taiga-ui/kit/abstract'; +import {TuiSliderComponent} from '@taiga-ui/kit/components/slider'; import {TUI_FROM_TO_TEXTS} from '@taiga-ui/kit/tokens'; +import {TuiKeySteps} from '@taiga-ui/kit/types'; import {Observable} from 'rxjs'; +/** + * Turn on new `Range`'s version. + * The new version will behave almost the same as `Range` from the next major release. + * @deprecated TODO remove me in v3.0 and make `Range` always "new". + */ +@Directive({ + selector: 'tui-range[new]', +}) +export class TuiNewRangeDirective {} + // @dynamic @Component({ selector: 'tui-range', - templateUrl: '../../abstract/slider/slider.common.template.html', - styleUrls: ['../../abstract/slider/slider.common.style.less'], + templateUrl: './range.template.html', + styleUrls: ['./range.style.less'], changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '[attr.tabindex]': '-1', + '[attr.aria-disabled]': 'computedDisabled', + }, providers: [ { provide: TUI_FOCUSABLE_ITEM_ACCESSOR, @@ -39,6 +63,38 @@ export class TuiRangeComponent extends AbstractTuiSlider<[number, number]> implements TuiFocusableElementAccessor { + @Input() + @tuiDefaultProp() + min = 0; + + /** + * TODO: make `100` as default value (to be like native sliders) in v3.0 + */ + @Input() + @tuiDefaultProp() + max = Infinity; + + /** + * TODO: think about replacing this props by `step` (to be like native slider). + * It can be easy after refactor of keySteps. + */ + @Input() + @tuiDefaultProp() + steps = 0; + + /** + * TODO: think about replacing this props by `step` (to be like native slider). + * It can be easy after refactor of keySteps. + * */ + @Input() + @tuiDefaultProp(nonNegativeFiniteAssertion, 'Quantum must be a non-negative number') + quantum = 0; + + @ViewChildren(TuiSliderComponent, {read: ElementRef}) + slidersRefs: QueryList> = EMPTY_QUERY; + + lastActiveThumb: 'right' | 'left' = 'right'; + constructor( @Optional() @Self() @@ -48,69 +104,98 @@ export class TuiRangeComponent @Inject(DOCUMENT) documentRef: Document, @Inject(ElementRef) private readonly elementRef: ElementRef, @Inject(TUI_FROM_TO_TEXTS) fromToTexts$: Observable<[string, string]>, + @Optional() + @Inject(TuiNewRangeDirective) + readonly isNew: TuiNewRangeDirective | null, ) { super(control, changeDetectorRef, documentRef, fromToTexts$); } get nativeFocusableElement(): TuiNativeFocusableElement | null { - if (this.computedDisabled || !this.dotLeft || !this.dotRight) { + const [sliderLeftRef, sliderRightRef] = this.slidersRefs; + + if (this.computedDisabled || !sliderLeftRef || !sliderRightRef) { return null; } return this.isLeftFocusable - ? this.dotLeft.nativeElement - : this.dotRight.nativeElement; + ? sliderLeftRef.nativeElement + : sliderRightRef.nativeElement; } get focused(): boolean { return isNativeFocusedIn(this.elementRef.nativeElement); } + 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]); } + @HostBinding('style.--right.%') get right(): number { return 100 - 100 * this.getFractionFromValue(this.value[1]); } - protected getFallbackValue(): [number, number] { - return [0, 0]; + @HostListener('focusin', ['true']) + @HostListener('focusout', ['false']) + onFocused(focused: boolean) { + this.updateFocused(focused); } - protected processStep(increment: boolean, right: boolean) { - const fraction = this.getFractionFromValue(right ? this.value[1] : this.value[0]); - const step = this.computedStep; - const value = this.getValueFromFraction( - increment ? fraction + step : fraction - step, - ); + @HostListener('keydown.arrowUp.prevent', ['1', '$event.target']) + @HostListener('keydown.arrowRight.prevent', ['1', '$event.target']) + @HostListener('keydown.arrowLeft.prevent', ['-1', '$event.target']) + @HostListener('keydown.arrowDown.prevent', ['-1', '$event.target']) + changeByStep(coefficient: number, target: HTMLElement) { + const [sliderLeftRef, sliderRightRef] = this.slidersRefs; + const leftThumbElement = sliderLeftRef.nativeElement; + const rightThumbElement = sliderRightRef.nativeElement; - this.processValue(value, right); + const isRightThumb = + target === this.elementRef.nativeElement + ? this.lastActiveThumb === 'right' + : target === rightThumbElement; + 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; + + this.processValue(this.getValueFromFraction(newFractionValue), isRightThumb); + + if (activeThumbElement) { + activeThumbElement.focus(); + } } - protected processValue(value: number, right: boolean) { + processValue(value: number, right: boolean) { const guardedValue = this.valueGuard(value); - if (right === true) { + if (right) { this.updateEnd(guardedValue); } else { this.updateStart(guardedValue); } + + this.lastActiveThumb = right ? 'right' : 'left'; + } + + protected getFallbackValue(): [number, number] { + return [0, 0]; } - protected getCalibratedFractionFromEvents( - rect: ClientRect, - clientX: number, - isMouseDownRight: boolean, - ): number { - const value = - clientX - - rect.left - - DOT_WIDTH[this.size] / 2 - - (isMouseDownRight ? DOT_WIDTH[this.size] : 0); - const total = rect.width - 2 * DOT_WIDTH[this.size]; - - return round(value / total, TUI_FLOATING_PRECISION); + @tuiPure + private computePureKeySteps( + keySteps: TuiKeySteps | null, + min: number, + max: number, + ): TuiKeySteps { + return [[0, min], ...(keySteps || []), [100, max]]; } private updateStart(value: number) { diff --git a/projects/kit/components/range/range.module.ts b/projects/kit/components/range/range.module.ts index 01fa582075a3..5b710d2ab03b 100644 --- a/projects/kit/components/range/range.module.ts +++ b/projects/kit/components/range/range.module.ts @@ -1,5 +1,6 @@ import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; +import {FormsModule} from '@angular/forms'; import { TuiActiveZoneModule, TuiFocusableModule, @@ -7,8 +8,10 @@ import { TuiRepeatTimesModule, } from '@taiga-ui/cdk'; import {TuiFormatNumberPipeModule} from '@taiga-ui/core'; +import {TuiSliderModule} from '@taiga-ui/kit/components/slider'; -import {TuiRangeComponent} from './range.component'; +import {TuiNewRangeDirective, TuiRangeComponent} from './range.component'; +import {TuiRangeChangeDirective} from './range-change.directive'; @NgModule({ imports: [ @@ -18,8 +21,10 @@ import {TuiRangeComponent} from './range.component'; TuiActiveZoneModule, TuiFocusVisibleModule, TuiFormatNumberPipeModule, + TuiSliderModule, + FormsModule, ], - declarations: [TuiRangeComponent], - exports: [TuiRangeComponent], + declarations: [TuiRangeComponent, TuiRangeChangeDirective, TuiNewRangeDirective], + exports: [TuiRangeComponent, TuiRangeChangeDirective, TuiNewRangeDirective], }) export class TuiRangeModule {} diff --git a/projects/kit/components/range/range.style.less b/projects/kit/components/range/range.style.less new file mode 100644 index 000000000000..fece30f7e751 --- /dev/null +++ b/projects/kit/components/range/range.style.less @@ -0,0 +1,173 @@ +@import 'taiga-ui-local'; + +@track-height: 0.125rem; +@extra-click-area-space: 0.4375rem; + +:host { + position: relative; + display: block; + height: @track-height; + border-radius: var(--tui-radius-m); + background: var(--tui-base-03); + cursor: pointer; + outline: none; + margin: @extra-click-area-space 0; + + &:active { + cursor: ew-resize; + } + + &:after { + content: ''; + position: absolute; + top: -@extra-click-area-space; + bottom: -@extra-click-area-space; + width: 100%; + } + + &._disabled { + opacity: var(--tui-disabled-opacity); + cursor: auto; + } +} + +.track(@thumb-width) { + position: relative; + margin: 0 @thumb-width / 2; + height: 100%; + + /* Filled selected range */ + &:after { + content: ''; + position: absolute; + top: 0; + left: var(--left); + right: var(--right); + height: 100%; + background: var(--tui-primary); + margin: 0 -@thumb-width / 2; + } +} + +.track { + :host[data-size='s'] & { + .track(@thumb-diameters[@s]); + } + + :host[data-size='m'] & { + .track(@thumb-diameters[@m]); + } +} + +.ignore-track-pointer-events() { + pointer-events: none; + + &::-webkit-slider-thumb { + pointer-events: all; + } + + &::-moz-range-thumb { + pointer-events: all; + } +} + +.remove-track-background() { + /* Artificially increased specificity */ + input[type='range']& { + &::-webkit-slider-runnable-track { + background: transparent; + } + + &::-moz-range-track { + background: transparent; + } + + &::-moz-range-progress { + background: transparent; + } + + /* Not-chromium Edge (16-18) */ + &::-ms-track { + background: transparent; + } + + &::-ms-fill-lower { + background: transparent; + } + } +} + +.thumb { + .ignore-track-pointer-events(); + .remove-track-background(); + position: absolute; + top: @track-height / 2; + left: 0; + right: 0; + z-index: 1; + transform: translate(0, -50%); + + &:last-of-type::-webkit-slider-thumb { + transform: translate(50%, 0); + } + + &:first-of-type::-webkit-slider-thumb { + transform: translate(-50%, 0); + } + + &:last-of-type::-moz-range-thumb { + transform: translate(50%, 0); + } + + &:first-of-type::-moz-range-thumb { + transform: translate(-50%, 0); + } + + :host._disabled & { + opacity: 1; // prevent double overlay for disabled state + } +} + +// TODO delete in v3.0 +.segments { + .fullsize(absolute, inset); + + :host[data-size='s'] & { + .tui-slider-ticks-labels(s); + padding: 0 @thumb-diameters[ @s] / 2; + } + + :host[data-size='m'] & { + .tui-slider-ticks-labels(m); + padding: 0 @thumb-diameters[ @m] / 2; + } +} + +// TODO Use background-image with repeating-linear-gradient to make ticks +.segment:not(:last-of-type):not(:first-of-type):before { + content: ''; + position: absolute; + left: 0; + right: 0; + margin: auto; + background: var(--tui-base-07); + width: 0.25rem; + height: 100%; +} + +// TODO delete in v3.0 +.segment { + &:last-of-type .number { + margin-right: -@thumb-diameters[ @m] / 2; + } + + &:first-of-type .number { + margin-left: -@thumb-diameters[ @m] / 2; + } +} + +// TODO delete in v3.0 +.number { + display: inline-block; + margin-top: 0.5rem; +} diff --git a/projects/kit/components/range/range.template.html b/projects/kit/components/range/range.template.html new file mode 100644 index 000000000000..67e5e8572847 --- /dev/null +++ b/projects/kit/components/range/range.template.html @@ -0,0 +1,57 @@ +
+ + +
+ + +
+ + + + {{ getSegmentPrefix(tickIndex, fromToText) }} {{ tickIndex | + tuiSliderTickLabel:segments:computedKeySteps | tuiFormatNumber + }} + + {{ tickIndex | tuiSliderTickLabel:segments:computedKeySteps + | i18nPlural: pluralizeMap }} + + + + +
diff --git a/projects/kit/components/range/test/range.component.spec.ts b/projects/kit/components/range/test/range.component.spec.ts index e334fc431d1a..46db3c6dd91f 100644 --- a/projects/kit/components/range/test/range.component.spec.ts +++ b/projects/kit/components/range/test/range.component.spec.ts @@ -1,8 +1,8 @@ -import {Component, ViewChild} from '@angular/core'; +import {Component, ElementRef, ViewChild} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {FormControl, ReactiveFormsModule} from '@angular/forms'; -import {PageObject} from '@taiga-ui/testing'; -import {NG_EVENT_PLUGINS} from '@tinkoff/ng-event-plugins'; +import {TuiRootModule} from '@taiga-ui/core'; +import {createKeyboardEvent, PageObject} from '@taiga-ui/testing'; import {TuiRangeComponent} from '../range.component'; import {TuiRangeModule} from '../range.module'; @@ -10,20 +10,25 @@ import {TuiRangeModule} from '../range.module'; describe('Range', () => { @Component({ template: ` - + + + `, }) class TestComponent { @ViewChild(TuiRangeComponent, {static: true}) component!: TuiRangeComponent; + @ViewChild(TuiRangeComponent, {static: true, read: ElementRef}) + elementRef!: ElementRef; + testValue = new FormControl([3, 5]); max = 11; min = 1; @@ -35,15 +40,11 @@ describe('Range', () => { let fixture: ComponentFixture; let testComponent: TestComponent; let pageObject: PageObject; - const keydownArrowLeft = new KeyboardEvent('keydown', { - key: 'arrowLeft', - }); - const keydownArrowRight = new KeyboardEvent('keydown', { - key: 'arrowRight', - }); + const keydownArrowLeft = createKeyboardEvent('ArrowLeft', 'keydown'); + const keydownArrowRight = createKeyboardEvent('ArrowRight', 'keydown'); const testContext = { get prefix() { - return 'tui-slider__'; + return 'tui-range__'; }, }; @@ -55,15 +56,19 @@ describe('Range', () => { return pageObject.getByAutomationId(`${testContext.prefix}right`)!.nativeElement; } - function getBar(): HTMLElement { - return pageObject.getByAutomationId(`${testContext.prefix}bar`)!.nativeElement; + function getFilledRangeOffeset(): {left: string; right: string} { + const computedStyles = testComponent.elementRef.nativeElement; + + return { + left: getComputedStyle(computedStyles).getPropertyValue('--left'), + right: getComputedStyle(computedStyles).getPropertyValue('--right'), + }; } beforeEach(() => { TestBed.configureTestingModule({ - imports: [ReactiveFormsModule, TuiRangeModule], + imports: [ReactiveFormsModule, TuiRootModule, TuiRangeModule], declarations: [TestComponent], - providers: NG_EVENT_PLUGINS, }); fixture = TestBed.createComponent(TestComponent); @@ -73,8 +78,8 @@ describe('Range', () => { }); it('The bar is filled from 20% to 40%', () => { - expect(getBar().style.left).toBe('20%'); - expect(getBar().style.right).toBe('60%'); + expect(getFilledRangeOffeset().left).toBe('20%'); + expect(getFilledRangeOffeset().right).toBe('60%'); }); describe('Changing values', () => { @@ -97,16 +102,16 @@ describe('Range', () => { getLeft().dispatchEvent(keydownArrowLeft); fixture.detectChanges(); - expect(getBar().style.left).toBe('10%'); - expect(getBar().style.right).toBe('60%'); + expect(getFilledRangeOffeset().left).toBe('10%'); + expect(getFilledRangeOffeset().right).toBe('60%'); }); it('Pressing the right arrow correctly paints the strip', () => { getLeft().dispatchEvent(keydownArrowRight); fixture.detectChanges(); - expect(getBar().style.left).toBe('30%'); - expect(getBar().style.right).toBe('60%'); + expect(getFilledRangeOffeset().left).toBe('30%'); + expect(getFilledRangeOffeset().right).toBe('60%'); }); }); @@ -129,16 +134,16 @@ describe('Range', () => { getRight().dispatchEvent(keydownArrowLeft); fixture.detectChanges(); - expect(getBar().style.left).toBe('20%'); - expect(getBar().style.right).toBe('70%'); + expect(getFilledRangeOffeset().left).toBe('20%'); + expect(getFilledRangeOffeset().right).toBe('70%'); }); it('Pressing the right arrow correctly paints the strip', () => { getRight().dispatchEvent(keydownArrowRight); fixture.detectChanges(); - expect(getBar().style.left).toBe('20%'); - expect(getBar().style.right).toBe('50%'); + expect(getFilledRangeOffeset().left).toBe('20%'); + expect(getFilledRangeOffeset().right).toBe('50%'); }); }); diff --git a/projects/kit/components/slider/index.ts b/projects/kit/components/slider/index.ts index 5252f81cacaf..fe35d3c2694b 100644 --- a/projects/kit/components/slider/index.ts +++ b/projects/kit/components/slider/index.ts @@ -3,3 +3,4 @@ export * from './slider.module'; export * from './slider-key-steps.directive'; export * from './slider-old.component'; export * from './slider-options'; +export * from './slider-readonly.directive'; diff --git a/projects/kit/components/slider/slider-old.component.ts b/projects/kit/components/slider/slider-old.component.ts index c71205550d17..921ece95ce23 100644 --- a/projects/kit/components/slider/slider-old.component.ts +++ b/projects/kit/components/slider/slider-old.component.ts @@ -71,6 +71,18 @@ export class TuiSliderOldComponent return 100 - 100 * this.getFractionFromValue(this.value); } + processValue(value: number) { + this.updateValue(this.valueGuard(value)); + } + + decrement() { + this.processStep(false); + } + + increment() { + this.processStep(true); + } + protected getFallbackValue(): number { return 0; } @@ -84,8 +96,4 @@ export class TuiSliderOldComponent this.processValue(value); } - - protected processValue(value: number) { - this.updateValue(this.valueGuard(value)); - } } diff --git a/projects/kit/components/slider/slider-readonly.directive.ts b/projects/kit/components/slider/slider-readonly.directive.ts new file mode 100644 index 000000000000..9ceadd634711 --- /dev/null +++ b/projects/kit/components/slider/slider-readonly.directive.ts @@ -0,0 +1,41 @@ +import {Directive, HostListener, Input} from '@angular/core'; +import {tuiDefaultProp} from '@taiga-ui/cdk'; + +const SLIDER_INTERACTION_KEYS = new Set([ + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + 'Home', + 'End', + 'PageUp', + 'PageDown', +]); + +/** + * Native doesn't work. + * This directive imitates this native behaviour. + */ +@Directive({ + selector: 'input[tuiSlider][readonly]', +}) +export class TuiSliderReadonlyDirective { + @Input() + @tuiDefaultProp() + readonly: '' | boolean = true; + + @HostListener('mousedown', ['$event']) + @HostListener('touchstart', ['$event']) + preventEvent(event: Event) { + if (this.readonly === '' || this.readonly) { + event.preventDefault(); + } + } + + @HostListener('keydown', ['$event']) + preventKeyboardInteraction(event: KeyboardEvent) { + if (SLIDER_INTERACTION_KEYS.has(event.key)) { + this.preventEvent(event); + } + } +} diff --git a/projects/kit/components/slider/slider.module.ts b/projects/kit/components/slider/slider.module.ts index 65a0cf86f473..e52e7195ac47 100644 --- a/projects/kit/components/slider/slider.module.ts +++ b/projects/kit/components/slider/slider.module.ts @@ -14,6 +14,7 @@ import { TuiSliderTickLabelPipe, } from './slider-key-steps.directive'; import {TuiSliderOldComponent} from './slider-old.component'; +import {TuiSliderReadonlyDirective} from './slider-readonly.directive'; @NgModule({ imports: [ @@ -27,12 +28,14 @@ import {TuiSliderOldComponent} from './slider-old.component'; declarations: [ TuiSliderComponent, TuiSliderKeyStepsDirective, + TuiSliderReadonlyDirective, TuiSliderTickLabelPipe, TuiSliderOldComponent, ], exports: [ TuiSliderComponent, TuiSliderKeyStepsDirective, + TuiSliderReadonlyDirective, TuiSliderTickLabelPipe, TuiSliderOldComponent, ], diff --git a/projects/kit/components/slider/slider.style.less b/projects/kit/components/slider/slider.style.less index d7169cf352eb..8516d4da7598 100644 --- a/projects/kit/components/slider/slider.style.less +++ b/projects/kit/components/slider/slider.style.less @@ -155,6 +155,7 @@ :host._old-edge { &::-ms-thumb { background: @thumb-color; + border-radius: 50%; } &::-ms-fill-lower { @@ -163,5 +164,6 @@ &::-ms-track { background: @track-color; + border: none; } }