From ae8dc83cc380f34feb927e296930b37770fe8c52 Mon Sep 17 00:00:00 2001 From: Nikita Barsukov Date: Tue, 17 Dec 2024 17:33:53 +0300 Subject: [PATCH] feat(kit): new refactored version of `InputNumber` --- .../tests/input-number/dynamic-postfix.cy.ts | 33 +- .../kit/input-number/input-number.pw.spec.ts | 636 ++++++++++++++++++ .../input-number/input-number.pw.spec.ts | 607 +++++++++-------- projects/demo/src/components/control/index.ts | 26 +- .../src/components/number-format/index.html | 141 ++++ .../src/components/number-format/index.ts | 48 ++ projects/demo/src/modules/app/app.routes.ts | 5 + projects/demo/src/modules/app/demo-routes.ts | 1 + projects/demo/src/modules/app/pages.ts | 13 +- .../number-format-documentation/index.ts | 3 + .../examples/1/index.html | 0 .../examples/1/index.less | 0 .../examples/1/index.ts | 0 .../examples/2/index.html | 0 .../examples/2/index.less | 0 .../examples/2/index.ts | 0 .../examples/3/index.html | 0 .../examples/3/index.ts | 0 .../examples/4/index.html | 0 .../examples/4/index.less | 0 .../examples/4/index.ts | 0 .../examples/5/index.html | 0 .../examples/5/index.ts | 0 .../examples/6/index.html | 0 .../examples/6/index.ts | 0 .../examples/7/index.html | 0 .../examples/7/index.ts | 0 .../examples/8/index.html | 0 .../examples/8/index.ts | 0 .../examples/import/import.md | 15 + .../examples/import/template.md | 5 + .../components/input-number-legacy/index.html | 250 +++++++ .../components/input-number-legacy/index.ts | 46 ++ .../input-number/examples/import/import.md | 24 +- .../input-number/examples/import/template.md | 10 +- .../components/input-number/index.html | 315 +++------ .../modules/components/input-number/index.ts | 40 +- .../src/modules/components/input/index.html | 2 +- .../utils/tokens/examples/5/index.html | 2 +- .../src/modules/pipes/currency/index.html | 2 +- projects/kit/components/index.ts | 1 + projects/kit/components/input-number/index.ts | 2 + .../input-number/input-number.component.ts | 255 +++++++ .../input-number/input-number.options.ts | 31 + .../components/input-number/ng-package.json | 5 + .../input-password.component.ts | 3 - 46 files changed, 1919 insertions(+), 602 deletions(-) create mode 100644 projects/demo-playwright/tests/kit/input-number/input-number.pw.spec.ts create mode 100644 projects/demo/src/components/number-format/index.html create mode 100644 projects/demo/src/components/number-format/index.ts rename projects/demo/src/modules/components/{input-number => input-number-legacy}/examples/1/index.html (100%) rename projects/demo/src/modules/components/{input-number => input-number-legacy}/examples/1/index.less (100%) rename projects/demo/src/modules/components/{input-number => input-number-legacy}/examples/1/index.ts (100%) rename projects/demo/src/modules/components/{input-number => input-number-legacy}/examples/2/index.html (100%) rename projects/demo/src/modules/components/{input-number => input-number-legacy}/examples/2/index.less (100%) rename projects/demo/src/modules/components/{input-number => input-number-legacy}/examples/2/index.ts (100%) rename projects/demo/src/modules/components/{input-number => input-number-legacy}/examples/3/index.html (100%) rename projects/demo/src/modules/components/{input-number => input-number-legacy}/examples/3/index.ts (100%) rename projects/demo/src/modules/components/{input-number => input-number-legacy}/examples/4/index.html (100%) rename projects/demo/src/modules/components/{input-number => input-number-legacy}/examples/4/index.less (100%) rename projects/demo/src/modules/components/{input-number => input-number-legacy}/examples/4/index.ts (100%) rename projects/demo/src/modules/components/{input-number => input-number-legacy}/examples/5/index.html (100%) rename projects/demo/src/modules/components/{input-number => input-number-legacy}/examples/5/index.ts (100%) rename projects/demo/src/modules/components/{input-number => input-number-legacy}/examples/6/index.html (100%) rename projects/demo/src/modules/components/{input-number => input-number-legacy}/examples/6/index.ts (100%) rename projects/demo/src/modules/components/{input-number => input-number-legacy}/examples/7/index.html (100%) rename projects/demo/src/modules/components/{input-number => input-number-legacy}/examples/7/index.ts (100%) rename projects/demo/src/modules/components/{input-number => input-number-legacy}/examples/8/index.html (100%) rename projects/demo/src/modules/components/{input-number => input-number-legacy}/examples/8/index.ts (100%) create mode 100644 projects/demo/src/modules/components/input-number-legacy/examples/import/import.md create mode 100644 projects/demo/src/modules/components/input-number-legacy/examples/import/template.md create mode 100644 projects/demo/src/modules/components/input-number-legacy/index.html create mode 100644 projects/demo/src/modules/components/input-number-legacy/index.ts create mode 100644 projects/kit/components/input-number/index.ts create mode 100644 projects/kit/components/input-number/input-number.component.ts create mode 100644 projects/kit/components/input-number/input-number.options.ts create mode 100644 projects/kit/components/input-number/ng-package.json diff --git a/projects/demo-cypress/src/tests/input-number/dynamic-postfix.cy.ts b/projects/demo-cypress/src/tests/input-number/dynamic-postfix.cy.ts index 09e1237d9b1d..8a497fc203db 100644 --- a/projects/demo-cypress/src/tests/input-number/dynamic-postfix.cy.ts +++ b/projects/demo-cypress/src/tests/input-number/dynamic-postfix.cy.ts @@ -3,24 +3,21 @@ import '@angular/common/locales/global/ru'; import {I18nPluralPipe} from '@angular/common'; import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; -import {TuiRoot} from '@taiga-ui/core'; -import {TuiInputNumberModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; +import {TuiRoot, TuiTextfield} from '@taiga-ui/core'; +import {TuiInputNumber} from '@taiga-ui/kit'; @Component({ standalone: true, - imports: [ - FormsModule, - I18nPluralPipe, - TuiInputNumberModule, - TuiRoot, - TuiTextfieldControllerModule, - ], + imports: [FormsModule, I18nPluralPipe, TuiInputNumber, TuiRoot, TuiTextfield], template: ` - + + + `, changeDetection: ChangeDetectionStrategy.OnPush, @@ -28,10 +25,10 @@ import {TuiInputNumberModule, TuiTextfieldControllerModule} from '@taiga-ui/lega export class TestInputNumberWithPostfix { protected value: number | null = null; protected pluralMap = { - one: 'секунда', - few: 'секунды', - many: 'секунд', - other: 'секунды', + one: ' секунда', + few: ' секунды', + many: ' секунд', + other: ' секунды', }; } @@ -39,7 +36,7 @@ describe('InputNumber with dynamic postfix', () => { describe('Plural forms of seconds (locale ru-RU)', () => { beforeEach(() => { cy.mount(TestInputNumberWithPostfix); - cy.get('tui-input-number input').as('textfield'); + cy.get('[tuiInputNumber]').as('textfield'); }); const withPostfix = ( diff --git a/projects/demo-playwright/tests/kit/input-number/input-number.pw.spec.ts b/projects/demo-playwright/tests/kit/input-number/input-number.pw.spec.ts new file mode 100644 index 000000000000..0e51794475a2 --- /dev/null +++ b/projects/demo-playwright/tests/kit/input-number/input-number.pw.spec.ts @@ -0,0 +1,636 @@ +import {DemoRoute} from '@demo/routes'; +import { + CHAR_EM_DASH, + CHAR_EN_DASH, + CHAR_HYPHEN, + CHAR_MINUS, + CMD, + TuiDocumentationApiPagePO, + tuiGoto, +} from '@demo-playwright/utils'; +import type {Locator} from '@playwright/test'; +import {expect, test} from '@playwright/test'; + +const {describe, beforeEach} = test; + +describe('InputNumber', () => { + let example: Locator; + let textfield: Locator; + + describe('API', () => { + beforeEach(({page}) => { + example = new TuiDocumentationApiPagePO(page).apiPageExample; + textfield = example.locator('[tuiInputNumber]'); + }); + + describe('[min] prop', () => { + describe('[min] property is positive number', () => { + test('rejects minus sign', async ({page}) => { + await tuiGoto(page, `${DemoRoute.InputNumber}/API?min=5`); + await textfield.fill( + `${CHAR_MINUS}${CHAR_HYPHEN}${CHAR_EN_DASH}${CHAR_EM_DASH}9`, + ); + + await expect(textfield).toHaveValue('9'); + await expect(textfield).toHaveJSProperty('selectionStart', 1); + await expect(textfield).toHaveJSProperty('selectionEnd', 1); + }); + + test('validates positive value (less than [min]) only on blur', async ({ + page, + }) => { + await tuiGoto(page, `${DemoRoute.InputNumber}/API?min=5`); + await textfield.fill('2'); + + await expect(textfield).toHaveValue('2'); + await expect(textfield).toHaveJSProperty('selectionStart', 1); + await expect(textfield).toHaveJSProperty('selectionEnd', 1); + + await textfield.blur(); + + await expect(textfield).toHaveValue('5'); + }); + + test('allows to enter multi-length positive value (which is less than [min])', async ({ + page, + }) => { + await tuiGoto(page, `${DemoRoute.InputNumber}/API?min=100`); + + await textfield.fill('3'); // less than min + + await expect(textfield).toHaveValue('3'); + + await textfield.pressSequentially('3'); // still less than min + + await expect(textfield).toHaveValue('33'); + + await textfield.fill('333'); // more than min + + await expect(textfield).toHaveValue('333'); + }); + }); + + describe('[min] property is negative number', () => { + beforeEach(async ({page}) => { + await tuiGoto(page, `${DemoRoute.InputNumber}/API?min=-5`); + + await textfield.clear(); + }); + + test('immediately validates negative value', async () => { + await textfield.fill('-10'); // less than [min] + + await expect(textfield).toHaveJSProperty('selectionStart', 2); + await expect(textfield).toHaveJSProperty('selectionEnd', 2); + await expect(textfield).toHaveValue(`${CHAR_MINUS}5`); + }); + + test('do not touch any positive value', async ({page}) => { + await textfield.fill('1'); + + await expect(textfield).toHaveJSProperty('selectionStart', 1); + await expect(textfield).toHaveJSProperty('selectionEnd', 1); + await expect(textfield).toHaveValue('1'); + + await textfield.pressSequentially('0'); + + await expect(textfield).toHaveJSProperty('selectionStart', 2); + await expect(textfield).toHaveJSProperty('selectionEnd', 2); + await expect(textfield).toHaveValue('10'); + + await textfield.blur(); + + await expect(textfield).toHaveValue('10'); + + await page.waitForTimeout(100); // to be sure that value is not changed even in case of some async validation + + await expect(textfield).toHaveValue('10'); + }); + }); + }); + + describe('[max] prop', () => { + describe('[max] property is negative number', () => { + beforeEach(async ({page}) => { + await tuiGoto(page, `${DemoRoute.InputNumber}/API?max=-5`); + }); + + test('validates negative value only on blur', async ({page}) => { + await textfield.fill('-1'); // more than [max] + await page.waitForTimeout(100); // to be sure that value is not changed even in case of some async validation + + await expect(textfield).toHaveValue(`${CHAR_MINUS}1`); + await expect(textfield).toHaveJSProperty('selectionStart', 2); + await expect(textfield).toHaveJSProperty('selectionEnd', 2); + + await textfield.blur(); + + await expect(textfield).toHaveValue(`${CHAR_MINUS}5`); + await expect(textfield).toHaveJSProperty('selectionStart', 2); + await expect(textfield).toHaveJSProperty('selectionEnd', 2); + }); + }); + + describe('[max] property is positive number', () => { + beforeEach(async ({page}) => { + await tuiGoto(page, `${DemoRoute.InputNumber}/API?max=12`); + }); + + test('immediately validates positive value', async () => { + await textfield.fill('19'); // more than max + + await expect(textfield).toHaveValue('12'); + await expect(textfield).toHaveJSProperty('selectionStart', 2); + await expect(textfield).toHaveJSProperty('selectionEnd', 2); + }); + + test('do not touch any negative value', async ({page}) => { + await textfield.fill('-1'); + + await expect(textfield).toHaveValue(`${CHAR_MINUS}1`); + await expect(textfield).toHaveJSProperty('selectionStart', 2); + await expect(textfield).toHaveJSProperty('selectionEnd', 2); + + await page.keyboard.down('9'); + + await expect(textfield).toHaveJSProperty('selectionStart', 3); + await expect(textfield).toHaveJSProperty('selectionEnd', 3); + await expect(textfield).toHaveValue(`${CHAR_MINUS}19`); + + await page.waitForTimeout(100); // to ensure that value is not changed even in case of some async validation + + await expect(textfield).toHaveValue(`${CHAR_MINUS}19`); + await expect(textfield).toHaveJSProperty('selectionStart', 3); + await expect(textfield).toHaveJSProperty('selectionEnd', 3); + }); + }); + }); + + describe('[prefix] & [postfix] props', () => { + ( + [ + {prefix: '$', postfix: ''}, + {prefix: '', postfix: 'kg'}, + {prefix: '$', postfix: 'kg'}, + ] as const + ).forEach(({prefix, postfix}) => { + describe(`[prefix]="${prefix}" & [postfix]="${postfix}"`, () => { + beforeEach(async ({page}) => { + await tuiGoto( + page, + `${DemoRoute.InputNumber}/API?prefix=${prefix}&postfix=${postfix}`, + ); + }); + + test('does not show suffixes for unfocused empty textfield', async () => { + await expect(textfield).toHaveValue(''); + }); + + test('shows suffixes for empty textfield on focus', async () => { + await textfield.focus(); + + await expect(textfield).toHaveValue(prefix + postfix); + }); + + test('does not shows prefix for READONLY empty textfield on focus', async ({ + page, + }) => { + await tuiGoto( + page, + `${DemoRoute.InputNumber}/API?prefix=${prefix}&postfix=${postfix}&readOnly=true`, + ); + await textfield.focus(); + + await expect(textfield).toHaveValue(''); + + await textfield.click(); + + await expect(textfield).toHaveValue(''); + }); + + describe('forbids to erase prefix', () => { + test('using Backspace many times', async () => { + await textfield.focus(); + await textfield.press('Backspace'); + await textfield.press('Backspace'); + + await expect(textfield).toHaveValue(prefix + postfix); + + await textfield.pressSequentially('42'); + + await expect(textfield).toHaveValue(`${prefix}42${postfix}`); + + await textfield.press('Backspace'); + await textfield.press('Backspace'); + await textfield.press('Backspace'); + await textfield.press('Backspace'); + await textfield.press('Backspace'); + + await expect(textfield).toHaveValue(prefix + postfix); + }); + + test('select all + Backspace', async ({page}) => { + await textfield.focus(); + await page.keyboard.press(`${CMD}+A`); + await page.keyboard.press('Backspace'); + + await expect(textfield).toHaveValue(prefix + postfix); + + await textfield.pressSequentially('42'); + + await expect(textfield).toHaveValue(`${prefix}42${postfix}`); + + await page.keyboard.press(`${CMD}+A`); + await page.keyboard.press('Backspace'); + + await expect(textfield).toHaveValue(prefix + postfix); + }); + + test('select all + Delete', async ({page}) => { + await textfield.focus(); + await page.keyboard.press(`${CMD}+A`); + await page.keyboard.press('Delete'); + + await expect(textfield).toHaveValue(prefix + postfix); + + await textfield.pressSequentially('42'); + + await expect(textfield).toHaveValue(`${prefix}42${postfix}`); + + await page.keyboard.press(`${CMD}+A`); + await page.keyboard.press('Delete'); + + await expect(textfield).toHaveValue(prefix + postfix); + }); + }); + + test('textfield does not contain any digit (only suffixes) => clear textfield value on blur', async ({ + browserName, + }) => { + // TODO + test.skip( + browserName !== 'chromium', + 'Investigate why it fails in Safari', + ); + + await textfield.focus(); + + await expect(textfield).toHaveValue(prefix + postfix); + await expect(textfield).toHaveJSProperty( + 'selectionStart', + prefix.length, + ); + await expect(textfield).toHaveJSProperty( + 'selectionEnd', + prefix.length, + ); + + await textfield.blur(); + + await expect(textfield).toHaveValue(''); + }); + }); + }); + }); + + describe('[precision] prop', () => { + test('[precision]=0', async ({page}) => { + await tuiGoto(page, `${DemoRoute.InputNumber}/API?precision=0`); + await textfield.focus(); + await textfield.pressSequentially(',.'); + + await expect(textfield).toHaveValue(''); + + await textfield.pressSequentially('0,.'); + + await expect(textfield).toHaveValue('0'); + }); + + test('[precision]=2', async ({page}) => { + await tuiGoto(page, `${DemoRoute.InputNumber}/API?precision=2`); + await textfield.focus(); + await textfield.pressSequentially(',.'); + + await expect(textfield).toHaveValue('0.'); + + await textfield.pressSequentially('12345'); + + await expect(textfield).toHaveValue('0.12'); + }); + }); + + describe('[thousandSeparator] prop', () => { + test('_', async ({page}) => { + await tuiGoto(page, `${DemoRoute.InputNumber}/API?thousandSeparator=_`); + await textfield.focus(); + await textfield.pressSequentially('1234567890'); + + await expect(textfield).toHaveValue('1_234_567_890'); + }); + + test('.', async ({page}) => { + await tuiGoto( + page, + /** + * TODO: drop `&decimalSeparator=,` after fixing this issue + * https://github.com/taiga-family/maskito/issues/1907 + */ + `${DemoRoute.InputNumber}/API?precision=0&thousandSeparator=.&decimalSeparator=,`, + ); + await textfield.focus(); + await textfield.pressSequentially('1234567890'); + + await expect(textfield).toHaveValue('1.234.567.890'); + }); + }); + + describe('[decimalSeparator] prop', () => { + test('.', async ({page}) => { + await tuiGoto( + page, + `${DemoRoute.InputNumber}/API?precision=4&decimalSeparator=.`, + ); + await textfield.focus(); + await textfield.pressSequentially('.1234567890'); + + await expect(textfield).toHaveValue('0.1234'); + + await textfield.clear(); + + await textfield.pressSequentially(',42'); + + await expect(textfield).toHaveValue('0.42'); + }); + }); + + describe('[decimalMode] prop', () => { + test('decimalMode=not-zero | 42 => Blur => 42', async ({page}) => { + await tuiGoto( + page, + `${DemoRoute.InputNumber}/API?precision=2&decimalMode=not-zero`, + ); + + await textfield.fill('42'); + + await expect(textfield).toHaveValue('42'); + await expect(textfield).toHaveJSProperty('selectionStart', 2); + await expect(textfield).toHaveJSProperty('selectionEnd', 2); + await expect(textfield).toHaveValue('42'); + }); + + test('decimalMode=not-zero | 42.1 => Blur => 42.1', async ({page}) => { + await tuiGoto( + page, + `${DemoRoute.InputNumber}/API?precision=2&decimalMode=not-zero`, + ); + + await textfield.fill('42.1'); + + await expect(textfield).toHaveValue('42.1'); + await expect(textfield).toHaveJSProperty('selectionStart', 4); + await expect(textfield).toHaveJSProperty('selectionEnd', 4); + + await textfield.blur(); + + await expect(textfield).toHaveValue('42.1'); + }); + + test('decimalMode=not-zero | 42.00 => Blur => 42', async ({page}) => { + await tuiGoto( + page, + `${DemoRoute.InputNumber}/API?precision=2&decimalMode=not-zero`, + ); + + await textfield.fill('42.00'); + + await expect(textfield).toHaveJSProperty( + 'selectionStart', + '42.00'.length, + ); + await expect(textfield).toHaveJSProperty('selectionEnd', '42.00'.length); + + await textfield.blur(); + + await expect(textfield).toHaveValue('42'); + }); + + test('decimalMode=pad | 42.1 => Blur => 42.10', async ({page}) => { + await tuiGoto( + page, + `${DemoRoute.InputNumber}/API?precision=2&decimalMode=pad`, + ); + + await textfield.fill('42.1'); + + await expect(textfield).toHaveValue('42.1'); + await expect(textfield).toHaveJSProperty('selectionStart', '42.1'.length); + await expect(textfield).toHaveJSProperty('selectionEnd', '42.1'.length); + + await textfield.blur(); + + await expect(textfield).toHaveValue('42.10'); + }); + + test('decimalMode=always | Enter 42 => 42.00', async ({page}) => { + await tuiGoto( + page, + `${DemoRoute.InputNumber}/API?precision=2&decimalMode=always`, + ); + await textfield.fill('42'); + + await expect(textfield).toHaveValue('42.00'); + await expect(textfield).toHaveJSProperty('selectionStart', 2); + await expect(textfield).toHaveJSProperty('selectionEnd', 2); + }); + }); + + describe('Caret navigation', () => { + describe('if user tries to erase padded decimal zeroes (decimalMode="always"), mask triggers caret navigation', () => { + beforeEach(async ({page}) => { + await tuiGoto( + page, + `${DemoRoute.InputNumber}/API?decimalMode=always&precision=2`, + ); + + await textfield.clear(); + await textfield.fill('105.00'); + }); + + test('105.00| => Backspace => 105.0|0', async ({page}) => { + await page.keyboard.press('Backspace'); + + await expect(textfield).toHaveJSProperty( + 'selectionStart', + '105.0'.length, + ); + await expect(textfield).toHaveJSProperty( + 'selectionEnd', + '105.0'.length, + ); + await expect(textfield).toHaveValue('105.00'); + }); + + test('105.0|0 => Backspace => 105.|00', async ({page}) => { + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('Backspace'); + + await expect(textfield).toHaveJSProperty( + 'selectionStart', + '105.'.length, + ); + await expect(textfield).toHaveJSProperty( + 'selectionEnd', + '105.'.length, + ); + await expect(textfield).toHaveValue('105.00'); + }); + + test('105.|00 => Backspace => 105|.00', async ({page}) => { + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('Backspace'); + + await expect(textfield).toHaveJSProperty( + 'selectionStart', + '105'.length, + ); + await expect(textfield).toHaveJSProperty( + 'selectionEnd', + '105'.length, + ); + await expect(textfield).toHaveValue('105.00'); + }); + + test('105.|00 => Delete => 105.0|0', async ({page}) => { + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('Delete'); + + await expect(textfield).toHaveJSProperty( + 'selectionStart', + '105.0'.length, + ); + await expect(textfield).toHaveJSProperty( + 'selectionEnd', + '105.0'.length, + ); + await expect(textfield).toHaveValue('105.00'); + }); + }); + + describe('if user tries to erase thousand separator, mask triggers caret navigation', () => { + beforeEach(async ({page}) => { + await tuiGoto( + page, + `${DemoRoute.InputNumber}/API?decimalMode=not-zero`, + ); + + await textfield.fill('1000'); + }); + + test('1| 000 => Delete => 1 |000', async ({page}) => { + const length = (await textfield.inputValue()).length; + + for (let i = 0; i < length; i++) { + await page.keyboard.press('ArrowLeft'); + } + + await expect(textfield).toHaveJSProperty('selectionStart', 0); + await expect(textfield).toHaveJSProperty('selectionEnd', 0); + + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('Delete'); + + await expect(textfield).toHaveJSProperty( + 'selectionStart', + '1 '.length, + ); + await expect(textfield).toHaveJSProperty('selectionEnd', '1 '.length); + await expect(textfield).toHaveValue('1 000'); + }); + + test('1 |000 => Backspace => 1| 000', async ({page}) => { + const length = (await textfield.inputValue()).length; + + for (let i = 0; i < length; i++) { + await page.keyboard.press('ArrowLeft'); + } + + await expect(textfield).toHaveJSProperty('selectionStart', 0); + await expect(textfield).toHaveJSProperty('selectionEnd', 0); + + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('Backspace'); + + await expect(textfield).toHaveJSProperty( + 'selectionStart', + '1'.length, + ); + await expect(textfield).toHaveJSProperty('selectionEnd', '1'.length); + await expect(textfield).toHaveValue('1 000'); + }); + }); + }); + + describe('[tuiTextfieldSize] prop', () => { + test.use({viewport: {width: 300, height: 500}}); + + ['s', 'm', 'l'].forEach((size) => { + test(`Empty textfield | ${size}`, async ({page}) => { + await tuiGoto( + page, + `${DemoRoute.InputNumber}/API?tuiTextfieldSize=${size}`, + ); + + await expect(textfield).toHaveValue(''); + await expect(example).toHaveScreenshot( + `input-number-unfocused-empty-size-${size}.png`, + ); + + await textfield.focus(); + + await expect(example).toHaveScreenshot( + `input-number-focused-empty-size-${size}.png`, + ); + }); + + test(`Textfield has value | ${size}`, async ({page}) => { + await tuiGoto( + page, + `${DemoRoute.InputNumber}/API?precision=2&tuiTextfieldSize=${size}`, + ); + + await textfield.fill('12.34'); + + await expect(textfield).toHaveValue('12.34'); + await expect(example).toHaveScreenshot( + `input-number-focused-with-value-size-${size}.png`, + ); + + await textfield.blur(); + + await expect(example).toHaveScreenshot( + `input-number-unfocused-with-value-size-${size}.png`, + ); + }); + }); + }); + + test('does not mutate already valid too large number on blur', async ({page}) => { + await tuiGoto( + page, + `${DemoRoute.InputNumber}/API?thousandSeparator=_&precision=2`, + ); + await textfield.focus(); + await textfield.clear(); + await textfield.pressSequentially('123456789012345.6789'); + + await expect(textfield).toHaveValue('123_456_789_012_345.67'); + + await textfield.blur(); + + await expect(textfield).toHaveValue('123_456_789_012_345.67'); + }); + }); +}); diff --git a/projects/demo-playwright/tests/legacy/input-number/input-number.pw.spec.ts b/projects/demo-playwright/tests/legacy/input-number/input-number.pw.spec.ts index 9193f7cce4ec..973306dd70f8 100644 --- a/projects/demo-playwright/tests/legacy/input-number/input-number.pw.spec.ts +++ b/projects/demo-playwright/tests/legacy/input-number/input-number.pw.spec.ts @@ -22,7 +22,7 @@ test.describe('InputNumber', () => { }); test('Infinite precision', async ({page}) => { - await tuiGoto(page, `${DemoRoute.InputNumber}/API?precision=Infinity`); + await tuiGoto(page, `${DemoRoute.InputNumberLegacy}/API?precision=Infinity`); await input.focus(); await input.fill('1,2345'); @@ -32,7 +32,7 @@ test.describe('InputNumber', () => { test('does not mutate already valid too large number on blur', async ({page}) => { await tuiGoto( page, - `${DemoRoute.InputNumber}/API?thousandSeparator=_&precision=2`, + `${DemoRoute.InputNumberLegacy}/API?thousandSeparator=_&precision=2`, ); await input.focus(); await input.clear(); @@ -48,7 +48,7 @@ test.describe('InputNumber', () => { test('prefix + value + postfix', async ({page}) => { await tuiGoto( page, - '/components/input-number/API?tuiTextfieldPrefix=$&tuiTextfieldPostfix=GBP', + `${DemoRoute.InputNumberLegacy}/API?tuiTextfieldPrefix=$&tuiTextfieldPostfix=GBP`, ); await expect(example).toHaveScreenshot('02-input-number.png'); @@ -64,7 +64,7 @@ test.describe('InputNumber', () => { await tuiGoto( page, - `/components/input-number/API?style.text-align=${align}&tuiTextfieldPrefix=${readableFormatText}&tuiTextfieldPostfix=${readableFormatText}`, + `${DemoRoute.InputNumberLegacy}/API?style.text-align=${align}&tuiTextfieldPrefix=${readableFormatText}&tuiTextfieldPostfix=${readableFormatText}`, ); await expect(example).toHaveScreenshot(`04-input-number-${i}.png`); @@ -76,421 +76,414 @@ test.describe('InputNumber', () => { test(`sandboxWidth=${sandboxWidth}`, async ({page}) => { await tuiGoto( page, - `/components/input-number/API?tuiTextfieldPostfix=$&tuiTextfieldPrefix=VeryLongText&sandboxWidth=${sandboxWidth}`, + `${DemoRoute.InputNumberLegacy}/API?tuiTextfieldPostfix=$&tuiTextfieldPrefix=VeryLongText&sandboxWidth=${sandboxWidth}`, ); await expect(example).toHaveScreenshot(`05-input-number-${i}.png`); }); }); }); - }); - - test.describe('Examples', () => { - test('cursor position', async ({page}) => { - await tuiGoto(page, `${DemoRoute.InputNumber}`); - const example = new TuiDocumentationPagePO(page).getExample('#currency'); - - await expect(example).toHaveScreenshot('06-input-number.png'); - - const inputs = await example - .getByTestId('tui-primitive-textfield__native-input') - .all(); + test.describe('Caret navigation', () => { + test.describe('if user tries to erase padded decimal zeroes (decimalMode="always"), mask triggers caret navigation', () => { + test.beforeEach(async ({page}) => { + await tuiGoto( + page, + `${DemoRoute.InputNumberLegacy}/API?decimalMode=always&precision=2`, + ); - for (const [i, input] of inputs.entries()) { - await input.clear(); - await input.focus(); + await input.clear(); + await input.fill('105,00'); + }); - await expect(example).toHaveScreenshot(`06-input-number-${i}.png`); - } - }); - }); + test('105,00| => Backspace => 105,0|0', async ({page}) => { + await page.keyboard.press('Backspace'); - test.describe('Caret navigation', () => { - test.describe('if user tries to erase padded decimal zeroes (decimalMode="always"), mask triggers caret navigation', () => { - test.beforeEach(async ({page}) => { - await tuiGoto( - page, - '/components/input-number/API?decimalMode=always&precision=2', - ); + await expect(input).toHaveJSProperty( + 'selectionStart', + '105,0'.length, + ); + await expect(input).toHaveJSProperty('selectionEnd', '105,0'.length); + await expect(example).toHaveScreenshot('07-input-number.png'); + }); - example = new TuiDocumentationApiPagePO(page).apiPageExample; - input = example.getByTestId('tui-primitive-textfield__native-input'); + test('105,0|0 => Backspace => 105,|00', async ({page}) => { + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('Backspace'); - await input.clear(); - await input.fill('105,00'); - }); + await expect(input).toHaveJSProperty('selectionStart', '105,'.length); + await expect(input).toHaveJSProperty('selectionEnd', '105,'.length); + await expect(example).toHaveScreenshot('08-input-number.png'); + }); - test('105,00| => Backspace => 105,0|0', async ({page}) => { - await page.keyboard.press('Backspace'); + test('105,|00 => Backspace => 105|,00', async ({page}) => { + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('Backspace'); - await expect(input).toHaveJSProperty('selectionStart', '105,0'.length); - await expect(input).toHaveJSProperty('selectionEnd', '105,0'.length); - await expect(example).toHaveScreenshot('07-input-number.png'); - }); + await expect(input).toHaveJSProperty('selectionStart', '105'.length); + await expect(input).toHaveJSProperty('selectionEnd', '105'.length); + await expect(example).toHaveScreenshot('09-input-number.png'); + }); - test('105,0|0 => Backspace => 105,|00', async ({page}) => { - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('Backspace'); + test('105,|00 => Delete => 105,0|0', async ({page}) => { + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('Delete'); - await expect(input).toHaveJSProperty('selectionStart', '105,'.length); - await expect(input).toHaveJSProperty('selectionEnd', '105,'.length); - await expect(example).toHaveScreenshot('08-input-number.png'); + await expect(input).toHaveJSProperty( + 'selectionStart', + '105,0'.length, + ); + await expect(input).toHaveJSProperty('selectionEnd', '105,0'.length); + await expect(example).toHaveScreenshot('10-input-number.png'); + }); }); - test('105,|00 => Backspace => 105|,00', async ({page}) => { - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('Backspace'); + test.describe('if user tries to erase thousand separator, mask triggers caret navigation', () => { + test.beforeEach(async ({page}) => { + await tuiGoto( + page, + `${DemoRoute.InputNumberLegacy}/API?decimalMode=not-zero`, + ); - await expect(input).toHaveJSProperty('selectionStart', '105'.length); - await expect(input).toHaveJSProperty('selectionEnd', '105'.length); - await expect(example).toHaveScreenshot('09-input-number.png'); - }); + await input.fill('1000'); + }); - test('105,|00 => Delete => 105,0|0', async ({page}) => { - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('Delete'); + test('1| 000 => Delete => 1 |000', async ({page}) => { + const length = (await input.inputValue()).length; - await expect(input).toHaveJSProperty('selectionStart', '105,0'.length); - await expect(input).toHaveJSProperty('selectionEnd', '105,0'.length); - await expect(example).toHaveScreenshot('10-input-number.png'); - }); - }); + for (let i = 0; i < length; i++) { + await page.keyboard.press('ArrowLeft'); + } - test.describe('if user tries to erase thousand separator, mask triggers caret navigation', () => { - test.beforeEach(async ({page}) => { - await tuiGoto(page, `${DemoRoute.InputNumber}/API?decimalMode=not-zero`); + await expect(input).toHaveJSProperty('selectionStart', 0); + await expect(input).toHaveJSProperty('selectionEnd', 0); - example = new TuiDocumentationApiPagePO(page).apiPageExample; - input = example.getByTestId('tui-primitive-textfield__native-input'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('Delete'); - await input.fill('1000'); - }); + await expect(input).toHaveJSProperty('selectionStart', '1 '.length); + await expect(input).toHaveJSProperty('selectionEnd', '1 '.length); + await expect(example).toHaveScreenshot('11-input-number.png'); + }); - test('1| 000 => Delete => 1 |000', async ({page}) => { - const length = (await input.inputValue()).length; + test('1 |000 => Backspace => 1| 000', async ({page}) => { + const length = (await input.inputValue()).length; - for (let i = 0; i < length; i++) { - await page.keyboard.press('ArrowLeft'); - } + for (let i = 0; i < length; i++) { + await page.keyboard.press('ArrowLeft'); + } - await expect(input).toHaveJSProperty('selectionStart', 0); - await expect(input).toHaveJSProperty('selectionEnd', 0); + await expect(input).toHaveJSProperty('selectionStart', 0); + await expect(input).toHaveJSProperty('selectionEnd', 0); - await page.keyboard.press('ArrowRight'); - await page.keyboard.press('Delete'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('Backspace'); - await expect(input).toHaveJSProperty('selectionStart', '1 '.length); - await expect(input).toHaveJSProperty('selectionEnd', '1 '.length); - await expect(example).toHaveScreenshot('11-input-number.png'); + await expect(input).toHaveJSProperty('selectionStart', '1'.length); + await expect(input).toHaveJSProperty('selectionEnd', '1'.length); + await expect(example).toHaveScreenshot('12-input-number.png'); + }); }); + }); - test('1 |000 => Backspace => 1| 000', async ({page}) => { - const length = (await input.inputValue()).length; + test.describe('[min] prop', () => { + test.describe('[min] property is positive number', () => { + test.beforeEach(async ({page}) => { + await tuiGoto(page, `${DemoRoute.InputNumberLegacy}/API?min=5`); - for (let i = 0; i < length; i++) { - await page.keyboard.press('ArrowLeft'); - } + await input.clear(); + }); - await expect(input).toHaveJSProperty('selectionStart', 0); - await expect(input).toHaveJSProperty('selectionEnd', 0); + test('rejects minus sign', async () => { + await input.fill( + `${CHAR_MINUS}${CHAR_HYPHEN}${CHAR_EN_DASH}${CHAR_EM_DASH}9`, + ); - await page.keyboard.press('ArrowRight'); - await page.keyboard.press('ArrowRight'); - await page.keyboard.press('Backspace'); + await expect(input).toHaveJSProperty('selectionStart', 1); + await expect(input).toHaveJSProperty('selectionEnd', 1); + await expect(example).toHaveScreenshot('13-input-number.png'); + }); - await expect(input).toHaveJSProperty('selectionStart', '1'.length); - await expect(input).toHaveJSProperty('selectionEnd', '1'.length); - await expect(example).toHaveScreenshot('12-input-number.png'); - }); - }); - }); + test('validates positive value only on blur', async () => { + await input.fill('2'); - test.describe('[min] prop', () => { - test.describe('[min] property is positive number', () => { - test.beforeEach(async ({page}) => { - await tuiGoto(page, `${DemoRoute.InputNumber}/API?min=5`); + await expect(input).toHaveJSProperty('selectionStart', 1); + await expect(input).toHaveJSProperty('selectionEnd', 1); + await expect(example).toHaveScreenshot('14-input-number.png'); - example = new TuiDocumentationApiPagePO(page).apiPageExample; - input = example.getByTestId('tui-primitive-textfield__native-input'); + await input.blur(); - await input.clear(); + await expect(example).toHaveScreenshot('15-input-number.png'); + }); }); - test('rejects minus sign', async () => { - await input.fill( - `${CHAR_MINUS}${CHAR_HYPHEN}${CHAR_EN_DASH}${CHAR_EM_DASH}9`, - ); + test.describe('[min] property is negative number', () => { + test.beforeEach(async ({page}) => { + await tuiGoto(page, `${DemoRoute.InputNumberLegacy}/API?min=-5`); - await expect(input).toHaveJSProperty('selectionStart', 1); - await expect(input).toHaveJSProperty('selectionEnd', 1); - await expect(example).toHaveScreenshot('13-input-number.png'); - }); + await input.clear(); + }); - test('validates positive value only on blur', async () => { - await input.fill('2'); + test('immediately validates negative value', async () => { + await input.fill('-10'); // less than [min] - await expect(input).toHaveJSProperty('selectionStart', 1); - await expect(input).toHaveJSProperty('selectionEnd', 1); - await expect(example).toHaveScreenshot('14-input-number.png'); + await expect(input).toHaveJSProperty('selectionStart', 2); + await expect(input).toHaveJSProperty('selectionEnd', 2); + await expect(input).toHaveValue(`${CHAR_MINUS}5`); + await expect(input).toHaveJSProperty('selectionStart', 2); + await expect(input).toHaveJSProperty('selectionEnd', 2); + await expect(example).toHaveScreenshot('16-input-number.png'); + }); - await input.blur(); + test("don't touch any positive value", async ({page}) => { + await input.fill('1'); - await expect(example).toHaveScreenshot('15-input-number.png'); - }); - }); + await expect(input).toHaveJSProperty('selectionStart', 1); + await expect(input).toHaveJSProperty('selectionEnd', 1); + await expect(example).toHaveScreenshot('17-input-number.png'); - test.describe('[min] property is negative number', () => { - test.beforeEach(async ({page}) => { - await tuiGoto(page, `${DemoRoute.InputNumber}/API?min=-5`); + await input.fill('0'); - example = new TuiDocumentationApiPagePO(page).apiPageExample; - input = example.getByTestId('tui-primitive-textfield__native-input'); + await expect(input).toHaveJSProperty('selectionStart', 1); + await expect(input).toHaveJSProperty('selectionEnd', 1); + await expect(example).toHaveScreenshot('18-input-number.png'); - await input.clear(); - }); + await input.blur(); - test('immediately validates negative value', async () => { - await input.fill('-10'); // less than [min] + await expect(input).toHaveJSProperty('selectionStart', 1); + await expect(input).toHaveJSProperty('selectionEnd', 1); - await expect(input).toHaveJSProperty('selectionStart', 2); - await expect(input).toHaveJSProperty('selectionEnd', 2); - await expect(input).toHaveValue(`${CHAR_MINUS}5`); - await expect(input).toHaveJSProperty('selectionStart', 2); - await expect(input).toHaveJSProperty('selectionEnd', 2); - await expect(example).toHaveScreenshot('16-input-number.png'); - }); + await page.waitForTimeout(100); // to be sure that value is not changed even in case of some async validation - test("don't touch any positive value", async ({page}) => { - await input.fill('1'); + await expect(example).toHaveScreenshot('19-input-number.png'); + }); + }); - await expect(input).toHaveJSProperty('selectionStart', 1); - await expect(input).toHaveJSProperty('selectionEnd', 1); - await expect(example).toHaveScreenshot('17-input-number.png'); + test('positive [min] property and positive value (which is less than [min])', async ({ + page, + }) => { + await tuiGoto(page, DemoRoute.InputNumberLegacy); - await input.fill('0'); + const example = new TuiDocumentationPagePO(page).getExample('#min-max'); + const textfield = example.getByRole('textbox'); - await expect(input).toHaveJSProperty('selectionStart', 1); - await expect(input).toHaveJSProperty('selectionEnd', 1); - await expect(example).toHaveScreenshot('18-input-number.png'); + await expect(textfield).toHaveValue(''); - await input.blur(); + textfield.fill('33'); // less than min - await expect(input).toHaveJSProperty('selectionStart', 1); - await expect(input).toHaveJSProperty('selectionEnd', 1); + await expect(example).toHaveScreenshot( + '20-input-number-positive-min-positive-wrong-value.png', + ); - await page.waitForTimeout(100); // to be sure that value is not changed even in case of some async validation + textfield.fill('333'); // more than min - await expect(example).toHaveScreenshot('19-input-number.png'); + await expect(example).toHaveScreenshot( + '20-input-number-positive-min-positive-valid-value.png', + ); }); }); - test('positive [min] property and positive value (which is less than [min])', async ({ - page, - }) => { - await tuiGoto(page, DemoRoute.InputNumber); + test.describe('[max] prop', () => { + test.describe('[max] property is negative number', () => { + test.beforeEach(async ({page}) => { + await tuiGoto(page, `${DemoRoute.InputNumberLegacy}/API?max=-5`); - const example = new TuiDocumentationPagePO(page).getExample('#min-max'); - const textfield = example.getByRole('textbox'); + example = new TuiDocumentationApiPagePO(page).apiPageExample; + input = example.getByTestId('tui-primitive-textfield__native-input'); - await expect(textfield).toHaveValue(''); + await input.clear(); + }); - textfield.fill('33'); // less than min + test('validates negative value only on blur', async ({page}) => { + await input.fill('-1'); // more than [max] + await page.waitForTimeout(100); // to be sure that value is not changed even in case of some async validation - await expect(example).toHaveScreenshot( - '20-input-number-positive-min-positive-wrong-value.png', - ); + await expect(input).toHaveValue(`${CHAR_MINUS}1`); + await expect(input).toHaveJSProperty('selectionStart', 2); + await expect(input).toHaveJSProperty('selectionEnd', 2); + await expect(example).toHaveScreenshot( + '21-input-number-before-blur.png', + ); - textfield.fill('333'); // more than min + await input.blur(); - await expect(example).toHaveScreenshot( - '20-input-number-positive-min-positive-valid-value.png', - ); - }); - }); + await expect(input).toHaveValue(`${CHAR_MINUS}5`); + await expect(input).toHaveJSProperty('selectionStart', 2); + await expect(input).toHaveJSProperty('selectionEnd', 2); + await expect(example).toHaveScreenshot( + '21-input-number-after-blur.png', + ); + }); + }); - test.describe('[max] prop', () => { - test.describe('[max] property is negative number', () => { - test.beforeEach(async ({page}) => { - await tuiGoto(page, `${DemoRoute.InputNumber}/API?max=-5`); + test.describe('[max] property is positive number', () => { + test.beforeEach(async ({page}) => { + await tuiGoto(page, `${DemoRoute.InputNumberLegacy}/API?max=12`); - example = new TuiDocumentationApiPagePO(page).apiPageExample; - input = example.getByTestId('tui-primitive-textfield__native-input'); + await input.clear(); + }); - await input.clear(); - }); + test('immediately validates positive value', async () => { + await input.fill('19'); - test('validates negative value only on blur', async ({page}) => { - await input.fill('-1'); // more than [max] - await page.waitForTimeout(100); // to be sure that value is not changed even in case of some async validation + await expect(input).toHaveValue('12'); + await expect(input).toHaveJSProperty('selectionStart', 2); + await expect(input).toHaveJSProperty('selectionEnd', 2); + await expect(example).toHaveScreenshot('22-input-number.png'); + }); - await expect(input).toHaveValue(`${CHAR_MINUS}1`); - await expect(input).toHaveJSProperty('selectionStart', 2); - await expect(input).toHaveJSProperty('selectionEnd', 2); - await expect(example).toHaveScreenshot('21-input-number-before-blur.png'); + test("don't touch any negative value", async ({page}) => { + await input.fill('-1'); - await input.blur(); + await expect(input).toHaveValue(`${CHAR_MINUS}1`); + await expect(input).toHaveJSProperty('selectionStart', 2); + await expect(input).toHaveJSProperty('selectionEnd', 2); + await expect(example).toHaveScreenshot('23-input-number.png'); - await expect(input).toHaveValue(`${CHAR_MINUS}5`); - await expect(input).toHaveJSProperty('selectionStart', 2); - await expect(input).toHaveJSProperty('selectionEnd', 2); - await expect(example).toHaveScreenshot('21-input-number-after-blur.png'); - }); - }); + await page.keyboard.down('9'); - test.describe('[max] property is positive number', () => { - test.beforeEach(async ({page}) => { - await tuiGoto(page, `${DemoRoute.InputNumber}/API?max=12`); + await expect(input).toHaveJSProperty('selectionStart', 3); + await expect(input).toHaveJSProperty('selectionEnd', 3); + await expect(input).toHaveValue(`${CHAR_MINUS}19`); + await expect(input).toHaveJSProperty('selectionStart', 3); + await expect(input).toHaveJSProperty('selectionEnd', 3); + await expect(example).toHaveScreenshot('24-input-number.png'); - example = new TuiDocumentationApiPagePO(page).apiPageExample; - input = example.getByTestId('tui-primitive-textfield__native-input'); + await page.waitForTimeout(100); - await input.clear(); + await expect(input).toHaveValue(`${CHAR_MINUS}19`); + await expect(input).toHaveJSProperty('selectionStart', 3); + await expect(input).toHaveJSProperty('selectionEnd', 3); + await expect(example).toHaveScreenshot('25-input-number.png'); + }); }); + }); + + test.describe('value formatting on blur', () => { + test('Value 42 (decimalMode=not-zero) => 42', async ({page}) => { + await tuiGoto( + page, + `${DemoRoute.InputNumberLegacy}/API?precision=2&decimalMode=not-zero`, + ); - test('immediately validates positive value', async () => { - await input.fill('19'); + await input.fill('42'); - await expect(input).toHaveValue('12'); + await expect(input).toHaveValue('42'); await expect(input).toHaveJSProperty('selectionStart', 2); await expect(input).toHaveJSProperty('selectionEnd', 2); - await expect(example).toHaveScreenshot('22-input-number.png'); + await expect(example).toHaveScreenshot('26-input-number.png'); }); - test("don't touch any negative value", async ({page}) => { - await input.fill('-1'); - - await expect(input).toHaveValue(`${CHAR_MINUS}1`); - await expect(input).toHaveJSProperty('selectionStart', 2); - await expect(input).toHaveJSProperty('selectionEnd', 2); - await expect(example).toHaveScreenshot('23-input-number.png'); + test('Value 42,1 (decimalMode=not-zero) => 42,1', async ({page}) => { + await tuiGoto( + page, + `${DemoRoute.InputNumberLegacy}/API?precision=2&decimalMode=not-zero`, + ); - await page.keyboard.down('9'); + await input.fill('42.1'); - await expect(input).toHaveJSProperty('selectionStart', 3); - await expect(input).toHaveJSProperty('selectionEnd', 3); - await expect(input).toHaveValue(`${CHAR_MINUS}19`); - await expect(input).toHaveJSProperty('selectionStart', 3); - await expect(input).toHaveJSProperty('selectionEnd', 3); - await expect(example).toHaveScreenshot('24-input-number.png'); + await expect(input).toHaveValue('42.1'); + await expect(input).toHaveJSProperty('selectionStart', 4); + await expect(input).toHaveJSProperty('selectionEnd', 4); - await page.waitForTimeout(100); + await input.blur(); - await expect(input).toHaveValue(`${CHAR_MINUS}19`); - await expect(input).toHaveJSProperty('selectionStart', 3); - await expect(input).toHaveJSProperty('selectionEnd', 3); - await expect(example).toHaveScreenshot('25-input-number.png'); + await expect(input).toHaveValue('42.1'); + await expect(example).toHaveScreenshot('27-input-number.png'); }); - }); - }); - - test.describe('value formatting on blur', () => { - test.beforeEach(({page}) => { - example = new TuiDocumentationApiPagePO(page).apiPageExample; - input = example.getByTestId('tui-primitive-textfield__native-input'); - }); - test('Value 42 (decimalMode=not-zero) => 42', async ({page}) => { - await tuiGoto( - page, - 'components/input-number/API?precision=2&decimalMode=not-zero', - ); + test('Value 42,1 (decimalMode=pad) => 42,10', async ({page}) => { + await tuiGoto( + page, + `${DemoRoute.InputNumberLegacy}/API?precision=2&decimalMode=pad`, + ); - await input.fill('42'); + await input.fill('42.1'); - await expect(input).toHaveValue('42'); - await expect(input).toHaveJSProperty('selectionStart', 2); - await expect(input).toHaveJSProperty('selectionEnd', 2); - await expect(example).toHaveScreenshot('26-input-number.png'); - }); + await expect(input).toHaveValue('42.1'); + await expect(input).toHaveJSProperty('selectionStart', 4); + await expect(input).toHaveJSProperty('selectionEnd', 4); - test('Value 42,1 (decimalMode=not-zero) => 42,1', async ({page}) => { - await tuiGoto( - page, - '/components/input-number/API?precision=2&decimalMode=not-zero', - ); + await input.blur(); - await input.fill('42.1'); + await expect(input).toHaveValue('42.10'); + await expect(example).toHaveScreenshot('28-input-number.png'); + }); - await expect(input).toHaveValue('42.1'); - await expect(input).toHaveJSProperty('selectionStart', 4); - await expect(input).toHaveJSProperty('selectionEnd', 4); + test('Value 42,00 (decimalMode=not-zero) => 42', async ({page}) => { + await tuiGoto( + page, + `${DemoRoute.InputNumberLegacy}/API?precision=2&decimalMode=not-zero`, + ); - await input.blur(); + await input.fill('42.00'); - await expect(input).toHaveValue('42.1'); - await expect(example).toHaveScreenshot('27-input-number.png'); - }); + await expect(input).toHaveJSProperty('selectionStart', 5); + await expect(input).toHaveJSProperty('selectionEnd', 5); + await expect(example).toHaveScreenshot('29-input-number.png'); + }); - test('Value 42,1 (decimalMode=pad) => 42,10', async ({page}) => { - await tuiGoto( - page, - '/components/input-number/API?precision=2&decimalMode=pad', - ); + test('Value 42,1 (precision=0) => 42', async ({page}) => { + await tuiGoto(page, `${DemoRoute.InputNumberLegacy}/API?precision=0`); - await input.fill('42.1'); + await input.fill('42.1'); - await expect(input).toHaveValue('42.1'); - await expect(input).toHaveJSProperty('selectionStart', 4); - await expect(input).toHaveJSProperty('selectionEnd', 4); + await expect(input).toHaveValue('42'); + await expect(example).toHaveScreenshot('30-input-number.png'); + }); - await input.blur(); + test('Value 42 (decimalMode=always) => 42.00', async ({page}) => { + await tuiGoto( + page, + `${DemoRoute.InputNumberLegacy}/API?precision=2&decimalMode=always`, + ); + await input.fill('42'); - await expect(input).toHaveValue('42.10'); - await expect(example).toHaveScreenshot('28-input-number.png'); - }); + await expect(input).toHaveValue('42.00'); + await expect(input).toHaveJSProperty('selectionStart', 2); + await expect(input).toHaveJSProperty('selectionEnd', 2); + await expect(example).toHaveScreenshot('31-input-number.png'); + }); - test('Value 42,00 (decimalMode=not-zero) => 42', async ({page}) => { - await tuiGoto( + test("text field does not contain any digit (only prefix + postfix) => clear text field's value on blur", async ({ page, - '/components/input-number/API?precision=2&decimalMode=not-zero', - ); - - await input.fill('42.00'); + }) => { + await tuiGoto( + page, + `${DemoRoute.InputNumberLegacy}/API?tuiTextfieldPrefix=$&tuiTextfieldPostfix=kg`, + ); + await input.clear(); + await input.focus(); - await expect(input).toHaveJSProperty('selectionStart', 5); - await expect(input).toHaveJSProperty('selectionEnd', 5); - await expect(example).toHaveScreenshot('29-input-number.png'); + await expect(input).toHaveValue('$ kg'); + await expect(input).toHaveJSProperty('selectionStart', 1); + await expect(input).toHaveJSProperty('selectionEnd', 1); + await expect(example).toHaveScreenshot('32-input-number.png'); + }); }); + }); - test('Value 42,1 (precision=0) => 42', async ({page}) => { - await tuiGoto(page, `${DemoRoute.InputNumber}/API?precision=0`); - - await input.fill('42.1'); + test.describe('Examples', () => { + test('cursor position', async ({page}) => { + await tuiGoto(page, `${DemoRoute.InputNumberLegacy}`); - await expect(input).toHaveValue('42'); - await expect(example).toHaveScreenshot('30-input-number.png'); - }); + const example = new TuiDocumentationPagePO(page).getExample('#currency'); - test('Value 42 (decimalMode=always) => 42.00', async ({page}) => { - await tuiGoto( - page, - '/components/input-number/API?precision=2&decimalMode=always', - ); - await input.fill('42'); + await expect(example).toHaveScreenshot('06-input-number.png'); - await expect(input).toHaveValue('42.00'); - await expect(input).toHaveJSProperty('selectionStart', 2); - await expect(input).toHaveJSProperty('selectionEnd', 2); - await expect(example).toHaveScreenshot('31-input-number.png'); - }); + const inputs = await example + .getByTestId('tui-primitive-textfield__native-input') + .all(); - test("text field does not contain any digit (only prefix + postfix) => clear text field's value on blur", async ({ - page, - }) => { - await tuiGoto( - page, - '/components/input-number/API?tuiTextfieldPrefix=$&tuiTextfieldPostfix=kg', - ); - await input.clear(); - await input.focus(); + for (const [i, input] of inputs.entries()) { + await input.clear(); + await input.focus(); - await expect(input).toHaveValue('$ kg'); - await expect(input).toHaveJSProperty('selectionStart', 1); - await expect(input).toHaveJSProperty('selectionEnd', 1); - await expect(example).toHaveScreenshot('32-input-number.png'); + await expect(example).toHaveScreenshot(`06-input-number-${i}.png`); + } }); }); }); diff --git a/projects/demo/src/components/control/index.ts b/projects/demo/src/components/control/index.ts index 03acc88675cc..c61e69548630 100644 --- a/projects/demo/src/components/control/index.ts +++ b/projects/demo/src/components/control/index.ts @@ -1,8 +1,28 @@ import {NgIf} from '@angular/common'; -import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + Directive, + inject, + Input, +} from '@angular/core'; +import {NgControl} from '@angular/forms'; import {TuiDocAPIItem} from '@taiga-ui/addon-doc'; import {TuiTitle} from '@taiga-ui/core'; +@Directive({ + standalone: true, + selector: 'input[tuiDisabled][formControl]', +}) +export class TuiDocReactiveFormDisable { + private readonly control = inject(NgControl); + + @Input() + public set tuiDisabled(x: boolean) { + this.control.control?.[x ? 'disable' : 'enable'](); + } +} + @Component({ standalone: true, selector: 'tbody[tuiDocControl]', @@ -10,7 +30,7 @@ import {TuiTitle} from '@taiga-ui/core'; templateUrl: './index.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class TuiDocControl { +export class TuiDocControlComponent { @Input() public hiddenOptions: ReadonlyArray<'disabled' | 'invalid' | 'readOnly'> = []; @@ -18,3 +38,5 @@ export class TuiDocControl { public disabled = false; public invalid: boolean | null = null; } + +export const TuiDocControl = [TuiDocControlComponent, TuiDocReactiveFormDisable]; diff --git a/projects/demo/src/components/number-format/index.html b/projects/demo/src/components/number-format/index.html new file mode 100644 index 000000000000..dc600e460a8b --- /dev/null +++ b/projects/demo/src/components/number-format/index.html @@ -0,0 +1,141 @@ + + + + + TuiNumberFormat + +
+ Usage example: +
+ [tuiNumberFormat]="{thousandSeparator, decimalSeparator, ..., rounding}" +
+
+ + + + + Symbol for separating thousands + + + + Symbol for separating fraction + + + + A number of digits after + [decimalSeparator] + ( + Infinity + for an untouched decimal part) + + + +
+
+ always +
+
+ number of digits after + [decimalSeparator] + is + always + equal to the precision. +
+ +
+ pad +
+
pads trailing zeroes up to precision, if the number is fractional
+ +
+ not-zero +
+
drops trailing zeroes
+
+ + + +
+
+ round +
+
+ rounds to the + nearest + number with the specified + [precision] +
+ +
+ floor +
+
+ rounds down (the + largest + number with the specified + [precision] + less than or equal to a given number) +
+ +
+ ceil +
+
+ rounds up (the + smallest + number with the specified + [precision] + greater than or equal to a given number) +
+ +
+ truncate +
+
+ returns the number with the specified + [precision] + by just removing extra fractional digits +
+
+ diff --git a/projects/demo/src/components/number-format/index.ts b/projects/demo/src/components/number-format/index.ts new file mode 100644 index 000000000000..45abd332bb60 --- /dev/null +++ b/projects/demo/src/components/number-format/index.ts @@ -0,0 +1,48 @@ +import {NgIf} from '@angular/common'; +import type {WritableSignal} from '@angular/core'; +import {ChangeDetectionStrategy, Component, Input, signal} from '@angular/core'; +import {RouterLink} from '@angular/router'; +import {DemoRoute} from '@demo/routes'; +import {TuiDocAPIItem} from '@taiga-ui/addon-doc'; +import type {TuiLooseUnion, TuiRounding} from '@taiga-ui/cdk'; +import type {TuiDecimalMode, TuiNumberFormatSettings} from '@taiga-ui/core'; +import {TUI_DEFAULT_NUMBER_FORMAT, TuiLink, TuiTitle} from '@taiga-ui/core'; +import {tuiInputNumberOptionsProvider} from '@taiga-ui/legacy'; + +@Component({ + standalone: true, + selector: 'tbody[tuiDocNumberFormat]', + imports: [NgIf, RouterLink, TuiDocAPIItem, TuiLink, TuiTitle], + templateUrl: './index.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + tuiInputNumberOptionsProvider({ + min: 0, + }), + ], +}) +export class TuiDocNumberFormat + implements + Record< + keyof TuiNumberFormatSettings, + WritableSignal + > +{ + protected readonly routes = DemoRoute; + protected readonly decimalVariants: TuiDecimalMode[] = ['always', 'pad', 'not-zero']; + protected readonly roundingVariants: TuiRounding[] = [ + 'truncate', + 'round', + 'ceil', + 'floor', + ]; + + @Input() + public hiddenOptions: Array> = []; + + public thousandSeparator = signal(TUI_DEFAULT_NUMBER_FORMAT.thousandSeparator); + public decimalSeparator = signal(TUI_DEFAULT_NUMBER_FORMAT.decimalSeparator); + public precision = signal(TUI_DEFAULT_NUMBER_FORMAT.precision); + public decimalMode = signal(TUI_DEFAULT_NUMBER_FORMAT.decimalMode); + public rounding = signal(TUI_DEFAULT_NUMBER_FORMAT.rounding); +} diff --git a/projects/demo/src/modules/app/app.routes.ts b/projects/demo/src/modules/app/app.routes.ts index e07ad4f1c191..ec4e46d903e8 100644 --- a/projects/demo/src/modules/app/app.routes.ts +++ b/projects/demo/src/modules/app/app.routes.ts @@ -424,6 +424,11 @@ export const ROUTES: Routes = [ loadComponent: async () => import('../components/input-number'), title: 'InputNumber', }), + route({ + path: DemoRoute.InputNumberLegacy, + loadComponent: async () => import('../components/input-number-legacy'), + title: 'InputNumber [deprecated]', + }), route({ path: DemoRoute.InputPhone, loadComponent: async () => import('../components/input-phone'), diff --git a/projects/demo/src/modules/app/demo-routes.ts b/projects/demo/src/modules/app/demo-routes.ts index e056dd65fac4..18ccc9df6d59 100644 --- a/projects/demo/src/modules/app/demo-routes.ts +++ b/projects/demo/src/modules/app/demo-routes.ts @@ -81,6 +81,7 @@ export const DemoRoute = { InputMonth: '/components/input-month', InputMonthRange: '/components/input-month-range', InputNumber: '/components/input-number', + InputNumberLegacy: '/legacy/input-number', InputPhone: '/components/input-phone', InputRange: '/components/input-range', InputDateRange: '/components/input-date-range', diff --git a/projects/demo/src/modules/app/pages.ts b/projects/demo/src/modules/app/pages.ts index d978607349e5..efac68b72cc6 100644 --- a/projects/demo/src/modules/app/pages.ts +++ b/projects/demo/src/modules/app/pages.ts @@ -549,13 +549,22 @@ export const pages: DocRoutePages = [ keywords: 'поле, инпут, форма, ввод, input, month, месяц, год, дата', route: DemoRoute.InputMonthRange, }, + // TODO: it will be revealed later + // { + // section: 'Components', + // title: 'InputNumber', + // keywords: + // 'поле, инпут, number, число, форма, ввод, input, money, деньги, ' + + // 'cash, копейки, рубли, доллары, евро, control, контрол', + // route: DemoRoute.InputNumber, + // }, { section: 'Components', - title: 'InputNumber', + title: 'InputNumber [deprecated]', keywords: 'поле, инпут, number, число, форма, ввод, input, money, деньги, ' + 'cash, копейки, рубли, доллары, евро, control, контрол', - route: DemoRoute.InputNumber, + route: DemoRoute.InputNumberLegacy, }, { section: 'Components', diff --git a/projects/demo/src/modules/components/abstract/number-format-documentation/index.ts b/projects/demo/src/modules/components/abstract/number-format-documentation/index.ts index c43690f24723..12fd61ed539e 100644 --- a/projects/demo/src/modules/components/abstract/number-format-documentation/index.ts +++ b/projects/demo/src/modules/components/abstract/number-format-documentation/index.ts @@ -11,6 +11,9 @@ import {TuiLink} from '@taiga-ui/core'; import {ABSTRACT_PROPS_ACCESSOR} from '../abstract-props-accessor'; import type {AbstractExampleTuiNumberFormat} from '../number-format'; +/** + * @deprecated use {@link TuiDocNumberFormat} + */ @Component({ standalone: true, selector: 'number-format-documentation', diff --git a/projects/demo/src/modules/components/input-number/examples/1/index.html b/projects/demo/src/modules/components/input-number-legacy/examples/1/index.html similarity index 100% rename from projects/demo/src/modules/components/input-number/examples/1/index.html rename to projects/demo/src/modules/components/input-number-legacy/examples/1/index.html diff --git a/projects/demo/src/modules/components/input-number/examples/1/index.less b/projects/demo/src/modules/components/input-number-legacy/examples/1/index.less similarity index 100% rename from projects/demo/src/modules/components/input-number/examples/1/index.less rename to projects/demo/src/modules/components/input-number-legacy/examples/1/index.less diff --git a/projects/demo/src/modules/components/input-number/examples/1/index.ts b/projects/demo/src/modules/components/input-number-legacy/examples/1/index.ts similarity index 100% rename from projects/demo/src/modules/components/input-number/examples/1/index.ts rename to projects/demo/src/modules/components/input-number-legacy/examples/1/index.ts diff --git a/projects/demo/src/modules/components/input-number/examples/2/index.html b/projects/demo/src/modules/components/input-number-legacy/examples/2/index.html similarity index 100% rename from projects/demo/src/modules/components/input-number/examples/2/index.html rename to projects/demo/src/modules/components/input-number-legacy/examples/2/index.html diff --git a/projects/demo/src/modules/components/input-number/examples/2/index.less b/projects/demo/src/modules/components/input-number-legacy/examples/2/index.less similarity index 100% rename from projects/demo/src/modules/components/input-number/examples/2/index.less rename to projects/demo/src/modules/components/input-number-legacy/examples/2/index.less diff --git a/projects/demo/src/modules/components/input-number/examples/2/index.ts b/projects/demo/src/modules/components/input-number-legacy/examples/2/index.ts similarity index 100% rename from projects/demo/src/modules/components/input-number/examples/2/index.ts rename to projects/demo/src/modules/components/input-number-legacy/examples/2/index.ts diff --git a/projects/demo/src/modules/components/input-number/examples/3/index.html b/projects/demo/src/modules/components/input-number-legacy/examples/3/index.html similarity index 100% rename from projects/demo/src/modules/components/input-number/examples/3/index.html rename to projects/demo/src/modules/components/input-number-legacy/examples/3/index.html diff --git a/projects/demo/src/modules/components/input-number/examples/3/index.ts b/projects/demo/src/modules/components/input-number-legacy/examples/3/index.ts similarity index 100% rename from projects/demo/src/modules/components/input-number/examples/3/index.ts rename to projects/demo/src/modules/components/input-number-legacy/examples/3/index.ts diff --git a/projects/demo/src/modules/components/input-number/examples/4/index.html b/projects/demo/src/modules/components/input-number-legacy/examples/4/index.html similarity index 100% rename from projects/demo/src/modules/components/input-number/examples/4/index.html rename to projects/demo/src/modules/components/input-number-legacy/examples/4/index.html diff --git a/projects/demo/src/modules/components/input-number/examples/4/index.less b/projects/demo/src/modules/components/input-number-legacy/examples/4/index.less similarity index 100% rename from projects/demo/src/modules/components/input-number/examples/4/index.less rename to projects/demo/src/modules/components/input-number-legacy/examples/4/index.less diff --git a/projects/demo/src/modules/components/input-number/examples/4/index.ts b/projects/demo/src/modules/components/input-number-legacy/examples/4/index.ts similarity index 100% rename from projects/demo/src/modules/components/input-number/examples/4/index.ts rename to projects/demo/src/modules/components/input-number-legacy/examples/4/index.ts diff --git a/projects/demo/src/modules/components/input-number/examples/5/index.html b/projects/demo/src/modules/components/input-number-legacy/examples/5/index.html similarity index 100% rename from projects/demo/src/modules/components/input-number/examples/5/index.html rename to projects/demo/src/modules/components/input-number-legacy/examples/5/index.html diff --git a/projects/demo/src/modules/components/input-number/examples/5/index.ts b/projects/demo/src/modules/components/input-number-legacy/examples/5/index.ts similarity index 100% rename from projects/demo/src/modules/components/input-number/examples/5/index.ts rename to projects/demo/src/modules/components/input-number-legacy/examples/5/index.ts diff --git a/projects/demo/src/modules/components/input-number/examples/6/index.html b/projects/demo/src/modules/components/input-number-legacy/examples/6/index.html similarity index 100% rename from projects/demo/src/modules/components/input-number/examples/6/index.html rename to projects/demo/src/modules/components/input-number-legacy/examples/6/index.html diff --git a/projects/demo/src/modules/components/input-number/examples/6/index.ts b/projects/demo/src/modules/components/input-number-legacy/examples/6/index.ts similarity index 100% rename from projects/demo/src/modules/components/input-number/examples/6/index.ts rename to projects/demo/src/modules/components/input-number-legacy/examples/6/index.ts diff --git a/projects/demo/src/modules/components/input-number/examples/7/index.html b/projects/demo/src/modules/components/input-number-legacy/examples/7/index.html similarity index 100% rename from projects/demo/src/modules/components/input-number/examples/7/index.html rename to projects/demo/src/modules/components/input-number-legacy/examples/7/index.html diff --git a/projects/demo/src/modules/components/input-number/examples/7/index.ts b/projects/demo/src/modules/components/input-number-legacy/examples/7/index.ts similarity index 100% rename from projects/demo/src/modules/components/input-number/examples/7/index.ts rename to projects/demo/src/modules/components/input-number-legacy/examples/7/index.ts diff --git a/projects/demo/src/modules/components/input-number/examples/8/index.html b/projects/demo/src/modules/components/input-number-legacy/examples/8/index.html similarity index 100% rename from projects/demo/src/modules/components/input-number/examples/8/index.html rename to projects/demo/src/modules/components/input-number-legacy/examples/8/index.html diff --git a/projects/demo/src/modules/components/input-number/examples/8/index.ts b/projects/demo/src/modules/components/input-number-legacy/examples/8/index.ts similarity index 100% rename from projects/demo/src/modules/components/input-number/examples/8/index.ts rename to projects/demo/src/modules/components/input-number-legacy/examples/8/index.ts diff --git a/projects/demo/src/modules/components/input-number-legacy/examples/import/import.md b/projects/demo/src/modules/components/input-number-legacy/examples/import/import.md new file mode 100644 index 000000000000..5261a0d39849 --- /dev/null +++ b/projects/demo/src/modules/components/input-number-legacy/examples/import/import.md @@ -0,0 +1,15 @@ +```ts +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {TuiInputNumberModule} from '@taiga-ui/legacy'; + +@Component({ + standalone: true, + imports: [FormsModule, ReactiveFormsModule, TuiInputNumberModule], + // ... +}) +export class Example { + testForm = new FormGroup({ + testValue: new FormControl(5000), + }); +} +``` diff --git a/projects/demo/src/modules/components/input-number-legacy/examples/import/template.md b/projects/demo/src/modules/components/input-number-legacy/examples/import/template.md new file mode 100644 index 000000000000..6427b847151e --- /dev/null +++ b/projects/demo/src/modules/components/input-number-legacy/examples/import/template.md @@ -0,0 +1,5 @@ +```html +
+ Type a sum +
+``` diff --git a/projects/demo/src/modules/components/input-number-legacy/index.html b/projects/demo/src/modules/components/input-number-legacy/index.html new file mode 100644 index 000000000000..2c0c25652852 --- /dev/null +++ b/projects/demo/src/modules/components/input-number-legacy/index.html @@ -0,0 +1,250 @@ + + +
A component to input numbers. Control value is also of number type.
+ +
+

There are also other components to input numbers:

+ +
+ +

+ Number formatting can be customized with + + TUI_NUMBER_FORMAT + + token. +

+ + + +

+ To input money use properties + [postfix] + or + [prefix] + . To get currency symbol use pipe + + tuiCurrency + + . +

+
+
+ + + + Customize input via + + TextfieldControllers + + . + + + + If you need to set some attributes or listen to events on native + input + , you can put it inside with + Textfield + directive as shown below + + + + + + Use property + [precision] + to configure a number of digits after comma. + + + + + + + + + + + + +
+ + + + + + Type a sum + + + + + + Disabled state (use + formControl.disable() + ) + + + Min value + + + Max value + + + Step to increase/decrease value with keyboard and buttons on the side + + + + + + Custom align content by text-align + + + + + +
diff --git a/projects/demo/src/modules/components/input-number-legacy/index.ts b/projects/demo/src/modules/components/input-number-legacy/index.ts new file mode 100644 index 000000000000..b131110fa629 --- /dev/null +++ b/projects/demo/src/modules/components/input-number-legacy/index.ts @@ -0,0 +1,46 @@ +import {Component} from '@angular/core'; +import {FormControl, ReactiveFormsModule, Validators} from '@angular/forms'; +import {changeDetection} from '@demo/emulate/change-detection'; +import {DemoRoute} from '@demo/routes'; +import {TuiDemo} from '@demo/utils'; +import {tuiProvide} from '@taiga-ui/cdk'; +import {TuiHint, TuiNumberFormat} from '@taiga-ui/core'; +import {TuiInputNumberModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; + +import {ABSTRACT_PROPS_ACCESSOR} from '../abstract/abstract-props-accessor'; +import {InheritedDocumentation} from '../abstract/inherited-documentation'; +import {AbstractExampleTuiNumberFormat} from '../abstract/number-format'; + +@Component({ + standalone: true, + imports: [ + InheritedDocumentation, + ReactiveFormsModule, + TuiDemo, + TuiHint, + TuiInputNumberModule, + TuiNumberFormat, + TuiTextfieldControllerModule, + ], + templateUrl: './index.html', + changeDetection, + providers: [tuiProvide(ABSTRACT_PROPS_ACCESSOR, PageComponent)], +}) +export default class PageComponent extends AbstractExampleTuiNumberFormat { + protected readonly routes = DemoRoute; + protected docPages = DemoRoute; + + protected readonly minVariants: readonly number[] = [-Infinity, -500, 5, 25]; + + protected min = this.minVariants[0]!; + + protected readonly maxVariants: readonly number[] = [Infinity, 10, 500]; + + protected max = this.maxVariants[0]!; + + protected step = 0; + + public override cleaner = false; + public override precision = 2; + public readonly control = new FormControl(6432, Validators.required); +} diff --git a/projects/demo/src/modules/components/input-number/examples/import/import.md b/projects/demo/src/modules/components/input-number/examples/import/import.md index 5261a0d39849..787a7dbd04df 100644 --- a/projects/demo/src/modules/components/input-number/examples/import/import.md +++ b/projects/demo/src/modules/components/input-number/examples/import/import.md @@ -1,15 +1,25 @@ ```ts -import {FormsModule, ReactiveFormsModule} from '@angular/forms'; -import {TuiInputNumberModule} from '@taiga-ui/legacy'; +import {ReactiveFormsModule} from '@angular/forms'; +import {TuiNumberFormat} from '@taiga-ui/core'; +import {TuiInputNumber, tuiInputNumberOptionsProvider} from '@taiga-ui/kit'; @Component({ standalone: true, - imports: [FormsModule, ReactiveFormsModule, TuiInputNumberModule], - // ... + imports: [ReactiveFormsModule, TuiInputNumber, TuiNumberFormat], + providers: [ + /** + * (Optional) + * Customize default behavior for all InputNumber-s + * inside specific Dependency Injection scope + */ + tuiInputNumberOptionsProvider({ + min: 0, + max: 100, + postfix: '%', + }), + ], }) export class Example { - testForm = new FormGroup({ - testValue: new FormControl(5000), - }); + protected readonly control = new FormControl(42); } ``` diff --git a/projects/demo/src/modules/components/input-number/examples/import/template.md b/projects/demo/src/modules/components/input-number/examples/import/template.md index 6427b847151e..b6e540fa7df1 100644 --- a/projects/demo/src/modules/components/input-number/examples/import/template.md +++ b/projects/demo/src/modules/components/input-number/examples/import/template.md @@ -1,5 +1,9 @@ ```html -
- Type a sum -
+ + + ``` diff --git a/projects/demo/src/modules/components/input-number/index.html b/projects/demo/src/modules/components/input-number/index.html index 1f2a2103e5b5..bfa7f99c660b 100644 --- a/projects/demo/src/modules/components/input-number/index.html +++ b/projects/demo/src/modules/components/input-number/index.html @@ -1,249 +1,100 @@ - -
A component to input numbers. Control value is also of number type.
- -
-

There are also other components to input numbers:

- -
+ Coming soon, stay tuned... -

- Number formatting can be customized with - - TUI_NUMBER_FORMAT - - token. -

- - - -

- To input money use properties - [postfix] - or - [prefix] - . To get currency symbol use pipe - - tuiCurrency - - . -

-
-
- - - - Customize input via - + + + - TextfieldControllers - - . - + - - If you need to set some attributes or listen to events on native - input - , you can put it inside with - Textfield - directive as shown below - - - - - - Use property - [precision] - to configure a number of digits after comma. + + - + + + + + The lowest value in the range of permitted values + - + + The greatest value in the range of permitted values + - + + Uneditable text + before + number + - + + Uneditable text + after + number + + - + - - + - - - - - Type a sum - - - - - - Disabled state (use - formControl.disable() - ) - - - Min value - - - Max value - - - Step to increase/decrease value with keyboard and buttons on the side - - - - - - Custom align content by text-align - - + +
diff --git a/projects/demo/src/modules/components/input-number/index.ts b/projects/demo/src/modules/components/input-number/index.ts index b131110fa629..45bdb4d1a9aa 100644 --- a/projects/demo/src/modules/components/input-number/index.ts +++ b/projects/demo/src/modules/components/input-number/index.ts @@ -1,46 +1,36 @@ import {Component} from '@angular/core'; import {FormControl, ReactiveFormsModule, Validators} from '@angular/forms'; +import {TuiDocControl} from '@demo/components/control'; +import {TuiDocNumberFormat} from '@demo/components/number-format'; +import {TuiDocTextfield} from '@demo/components/textfield'; import {changeDetection} from '@demo/emulate/change-detection'; -import {DemoRoute} from '@demo/routes'; import {TuiDemo} from '@demo/utils'; -import {tuiProvide} from '@taiga-ui/cdk'; -import {TuiHint, TuiNumberFormat} from '@taiga-ui/core'; -import {TuiInputNumberModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; - -import {ABSTRACT_PROPS_ACCESSOR} from '../abstract/abstract-props-accessor'; -import {InheritedDocumentation} from '../abstract/inherited-documentation'; -import {AbstractExampleTuiNumberFormat} from '../abstract/number-format'; +import {TuiHint, TuiNumberFormat, TuiTextfield} from '@taiga-ui/core'; +import {TuiInputNumber} from '@taiga-ui/kit'; @Component({ standalone: true, imports: [ - InheritedDocumentation, ReactiveFormsModule, TuiDemo, + TuiDocControl, + TuiDocNumberFormat, + TuiDocTextfield, TuiHint, - TuiInputNumberModule, + TuiInputNumber, TuiNumberFormat, - TuiTextfieldControllerModule, + TuiTextfield, ], templateUrl: './index.html', changeDetection, - providers: [tuiProvide(ABSTRACT_PROPS_ACCESSOR, PageComponent)], }) -export default class PageComponent extends AbstractExampleTuiNumberFormat { - protected readonly routes = DemoRoute; - protected docPages = DemoRoute; - +export default class PageComponent { + protected readonly control = new FormControl(null, Validators.required); + protected readonly maxVariants: readonly number[] = [Infinity, 10, 500]; protected readonly minVariants: readonly number[] = [-Infinity, -500, 5, 25]; protected min = this.minVariants[0]!; - - protected readonly maxVariants: readonly number[] = [Infinity, 10, 500]; - protected max = this.maxVariants[0]!; - - protected step = 0; - - public override cleaner = false; - public override precision = 2; - public readonly control = new FormControl(6432, Validators.required); + protected prefix = ''; + protected postfix = ''; } diff --git a/projects/demo/src/modules/components/input/index.html b/projects/demo/src/modules/components/input/index.html index 5fe7efabc156..a3240d3bd0ae 100644 --- a/projects/demo/src/modules/components/input/index.html +++ b/projects/demo/src/modules/components/input/index.html @@ -43,7 +43,7 @@
  • InputNumber diff --git a/projects/demo/src/modules/components/utils/tokens/examples/5/index.html b/projects/demo/src/modules/components/utils/tokens/examples/5/index.html index 904ea55f6dbb..4aa86769076f 100644 --- a/projects/demo/src/modules/components/utils/tokens/examples/5/index.html +++ b/projects/demo/src/modules/components/utils/tokens/examples/5/index.html @@ -51,7 +51,7 @@

    Components that are customizable:

  • TuiInputNumberComponent diff --git a/projects/demo/src/modules/pipes/currency/index.html b/projects/demo/src/modules/pipes/currency/index.html index d7c8a1c83d3f..6a66915ff79f 100644 --- a/projects/demo/src/modules/pipes/currency/index.html +++ b/projects/demo/src/modules/pipes/currency/index.html @@ -8,7 +8,7 @@ Pipe for transforming number into money. It is usually used with InputNumber diff --git a/projects/kit/components/index.ts b/projects/kit/components/index.ts index d9599844ac38..3e64f6678962 100644 --- a/projects/kit/components/index.ts +++ b/projects/kit/components/index.ts @@ -21,6 +21,7 @@ export * from '@taiga-ui/kit/components/elastic-container'; export * from '@taiga-ui/kit/components/files'; export * from '@taiga-ui/kit/components/filter'; export * from '@taiga-ui/kit/components/input-inline'; +export * from '@taiga-ui/kit/components/input-number'; export * from '@taiga-ui/kit/components/input-password'; export * from '@taiga-ui/kit/components/input-phone-international'; export * from '@taiga-ui/kit/components/items-with-more'; diff --git a/projects/kit/components/input-number/index.ts b/projects/kit/components/input-number/index.ts new file mode 100644 index 000000000000..e4e250600fe9 --- /dev/null +++ b/projects/kit/components/input-number/index.ts @@ -0,0 +1,2 @@ +export * from './input-number.component'; +export * from './input-number.options'; diff --git a/projects/kit/components/input-number/input-number.component.ts b/projects/kit/components/input-number/input-number.component.ts new file mode 100644 index 000000000000..8791c84cca2c --- /dev/null +++ b/projects/kit/components/input-number/input-number.component.ts @@ -0,0 +1,255 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + Input, + signal, +} from '@angular/core'; +import {toSignal} from '@angular/core/rxjs-interop'; +import {MaskitoDirective} from '@maskito/angular'; +import type {MaskitoOptions} from '@maskito/core'; +import {maskitoInitialCalibrationPlugin} from '@maskito/core'; +import { + maskitoCaretGuard, + maskitoNumberOptionsGenerator, + maskitoParseNumber, +} from '@maskito/kit'; +import {TuiControl} from '@taiga-ui/cdk/classes'; +import {CHAR_HYPHEN, CHAR_MINUS} from '@taiga-ui/cdk/constants'; +import {TUI_IS_IOS, tuiFallbackValueProvider} from '@taiga-ui/cdk/tokens'; +import {tuiInjectElement} from '@taiga-ui/cdk/utils/dom'; +import {tuiIsSafeToRound} from '@taiga-ui/cdk/utils/math'; +import {TuiTextfieldDirective} from '@taiga-ui/core/components/textfield'; +import {TUI_DEFAULT_NUMBER_FORMAT, TUI_NUMBER_FORMAT} from '@taiga-ui/core/tokens'; +import {tuiFormatNumber} from '@taiga-ui/core/utils/format'; +import {tuiMaskito} from '@taiga-ui/kit/utils'; + +import {TUI_INPUT_NUMBER_OPTIONS} from './input-number.options'; + +const DEFAULT_MAX_LENGTH = 18; + +@Component({ + standalone: true, + selector: 'input[tuiInputNumber]', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [tuiFallbackValueProvider(null)], + hostDirectives: [ + // TODO: replace with `TuiWithTextfield` after merging of https://github.com/taiga-family/taiga-ui/pull/9976 + { + directive: TuiTextfieldDirective, + inputs: ['invalid', 'focused', 'readOnly', 'state'], + }, + MaskitoDirective, + ], + host: { + '[value]': 'textfieldValue()', + '[disabled]': 'disabled()', + '[attr.inputMode]': 'inputMode()', + '[attr.maxLength]': 'maxLength()', + '(input)': 'onInput()', + '(blur)': 'onBlur()', + '(focus)': 'onFocus()', + }, +}) +export class TuiInputNumber extends TuiControl { + private readonly element = tuiInjectElement(); + private readonly isIOS = inject(TUI_IS_IOS); + private readonly options = inject(TUI_INPUT_NUMBER_OPTIONS); + private readonly numberFormat = toSignal(inject(TUI_NUMBER_FORMAT), { + initialValue: TUI_DEFAULT_NUMBER_FORMAT, + }); + + private readonly min = signal(this.options.min); + private readonly max = signal(this.options.max); + private readonly prefix = signal(this.options.prefix); + private readonly postfix = signal(this.options.postfix); + + private readonly precision = computed(() => + Number.isNaN(this.numberFormat().precision) ? 2 : this.numberFormat().precision, + ); + + private readonly isIntermediateState = computed(() => { + const value = maskitoParseNumber( + this.textfieldValue(), + this.numberFormat().decimalSeparator, + ); + + return value < 0 ? value > this.max() : value < this.min(); + }); + + protected textfieldValue = signal(this.element.value || ''); + protected override readonly transformer = this.options.valueTransformer; + + protected readonly inputMode = computed(() => { + if (this.isIOS && this.min() < 0) { + // iPhone does not have minus sign if inputMode is equal to 'numeric' / 'decimal' + return 'text'; + } + + return this.precision() ? 'decimal' : 'numeric'; + }); + + protected readonly maxLength = computed(() => { + const {decimalSeparator, thousandSeparator} = this.numberFormat(); + const decimalPart = + !!this.precision() && this.textfieldValue().includes(decimalSeparator); + const precision = decimalPart ? Math.min(this.precision() + 1, 20) : 0; + const takeThousand = thousandSeparator.repeat(5).length; + + return DEFAULT_MAX_LENGTH + precision + takeThousand; + }); + + protected readonly mask = tuiMaskito( + computed((numberFormat = this.numberFormat()) => + this.computeMask({ + ...numberFormat, + precision: this.precision(), + min: this.min(), + max: this.max(), + prefix: this.prefix(), + postfix: this.postfix(), + decimalZeroPadding: numberFormat.decimalMode === 'always', + }), + ), + ); + + @Input('min') + public set minSetter(x: number | null) { + this.updateMinMaxLimits(x, this.max()); + } + + @Input('max') + public set maxSetter(x: number | null) { + this.updateMinMaxLimits(this.min(), x); + } + + // TODO(v5): replace with signal input + @Input('prefix') + public set prefixSetter(x: string) { + this.prefix.set(x); + } + + // TODO(v5): replace with signal input + @Input('postfix') + public set postfixSetter(x: string) { + this.postfix.set(x); + } + + public override writeValue(value: number | null): void { + super.writeValue(value); + this.textfieldValue.set(this.formatNumber(value)); + } + + protected onInput(): void { + const value = this.element.value; + const parsedValue = maskitoParseNumber( + value, + this.numberFormat().decimalSeparator, + ); + + this.textfieldValue.set(value); + + if (Number.isNaN(parsedValue)) { + this.onChange(null); + + return; + } + + if ( + this.isIntermediateState() || + parsedValue < this.min() || + parsedValue > this.max() + ) { + return; + } + + this.onChange(parsedValue); + } + + protected onBlur(): void { + this.onTouched(); + + if (!this.isIntermediateState()) { + this.textfieldValue.set(this.formatNumber(this.value())); + } + } + + protected onFocus(): void { + const value = maskitoParseNumber( + this.textfieldValue(), + this.numberFormat().decimalSeparator, + ); + + if (Number.isNaN(value) && !this.readOnly()) { + this.textfieldValue.set(this.prefix() + this.postfix()); + } + } + + private formatNumber(value: number | null): string { + if (value === null) { + return ''; + } + + return ( + this.prefix() + + tuiFormatNumber(value, { + ...this.numberFormat(), + /** + * Number can satisfy interval [Number.MIN_SAFE_INTEGER; Number.MAX_SAFE_INTEGER] + * but its rounding can violate it. + * Before BigInt support there is no perfect solution – only trade off. + * No rounding is better than lose precision and incorrect mutation of already valid value. + */ + precision: tuiIsSafeToRound(value, this.precision()) + ? this.precision() + : Infinity, + }).replace(CHAR_HYPHEN, CHAR_MINUS) + + this.postfix() + ); + } + + private updateMinMaxLimits( + nullableMin: number | null, + nullableMax: number | null, + ): void { + const min = + this.transformer?.fromControlValue(nullableMin) ?? + nullableMin ?? + this.options.min; + const max = + this.transformer?.fromControlValue(nullableMax) ?? + nullableMax ?? + this.options.max; + + this.min.set(Math.min(min, max)); + this.max.set(Math.max(min, max)); + } + + private computeMask( + params: NonNullable[0]>, + ): MaskitoOptions { + const {prefix = '', postfix = ''} = params; + const {plugins, ...options} = maskitoNumberOptionsGenerator(params); + const initialCalibrationPlugin = maskitoInitialCalibrationPlugin( + maskitoNumberOptionsGenerator({ + ...params, + min: Number.MIN_SAFE_INTEGER, + max: Number.MAX_SAFE_INTEGER, + }), + ); + + return { + ...options, + plugins: [ + ...plugins, + initialCalibrationPlugin, + maskitoCaretGuard((value) => [ + prefix.length, + value.length - postfix.length, + ]), + ], + }; + } +} diff --git a/projects/kit/components/input-number/input-number.options.ts b/projects/kit/components/input-number/input-number.options.ts new file mode 100644 index 000000000000..4df74d2fb6fc --- /dev/null +++ b/projects/kit/components/input-number/input-number.options.ts @@ -0,0 +1,31 @@ +import type {Provider} from '@angular/core'; +import type {TuiValueTransformer} from '@taiga-ui/cdk/classes'; +import {tuiCreateToken, tuiProvideOptions} from '@taiga-ui/cdk/utils/miscellaneous'; + +export interface TuiInputNumberOptions { + readonly max: number; + readonly min: number; + readonly prefix: string; + readonly postfix: string; + readonly valueTransformer: TuiValueTransformer | null; +} + +export const TUI_INPUT_NUMBER_DEFAULT_OPTIONS: TuiInputNumberOptions = { + min: Number.MIN_SAFE_INTEGER, + max: Number.MAX_SAFE_INTEGER, + prefix: '', + postfix: '', + valueTransformer: null, +}; + +export const TUI_INPUT_NUMBER_OPTIONS = tuiCreateToken(TUI_INPUT_NUMBER_DEFAULT_OPTIONS); + +export function tuiInputNumberOptionsProvider( + options: Partial, +): Provider { + return tuiProvideOptions( + TUI_INPUT_NUMBER_OPTIONS, + options, + TUI_INPUT_NUMBER_DEFAULT_OPTIONS, + ); +} diff --git a/projects/kit/components/input-number/ng-package.json b/projects/kit/components/input-number/ng-package.json new file mode 100644 index 000000000000..bebf62dcb5e5 --- /dev/null +++ b/projects/kit/components/input-number/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "index.ts" + } +} diff --git a/projects/kit/components/input-password/input-password.component.ts b/projects/kit/components/input-password/input-password.component.ts index 39c81a9b7876..c39c15cf71f6 100644 --- a/projects/kit/components/input-password/input-password.component.ts +++ b/projects/kit/components/input-password/input-password.component.ts @@ -20,9 +20,6 @@ import {TUI_PASSWORD_TEXTS} from '@taiga-ui/kit/tokens'; import {TUI_INPUT_PASSWORD_OPTIONS} from './input-password.options'; -/** - * @deprecated use {@link TuiPassword} with {@link TuiTextfield} - */ @Component({ standalone: true, selector: 'input[tuiInputPassword]',