Repository: Tinkoff/maskito Branch: main Commit: f79688db8ac1 Files: 757 Total size: 1.6 MB Directory structure: gitextract_lbehwmqa/ ├── .cspell.json ├── .editorconfig ├── .firebaserc ├── .github/ │ ├── CODEOWNERS │ ├── CODE_OF_CONDUCT.md │ ├── CONTRIBUTING.md │ ├── ISSUE_TEMPLATE/ │ │ ├── 1-bug-report.yml │ │ ├── 2-feature-request.yml │ │ └── 3-documentation.yml │ ├── renovate.json │ └── workflows/ │ ├── assign-author.yml │ ├── auto-merge.yml │ ├── build.yml │ ├── deploy-preview.yml │ ├── deploy.yml │ ├── e2e.yml │ ├── lint.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .husky/ │ ├── commit-msg │ └── pre-commit ├── .npmrc ├── .release-it.js ├── .ws-context ├── CHANGELOG.md ├── LICENSE ├── README.md ├── codecov.yml ├── eslint.config.ts ├── firebase.json ├── jest.config.ts ├── jest.preset.js ├── nx.json ├── package.json ├── projects/ │ ├── angular/ │ │ ├── README.md │ │ ├── jest.config.ts │ │ ├── ng-package.json │ │ ├── package.json │ │ ├── project.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── lib/ │ │ │ │ ├── maskito.directive.ts │ │ │ │ ├── maskito.pipe.ts │ │ │ │ ├── pattern.directive.ts │ │ │ │ └── tests/ │ │ │ │ ├── maskito.directive.spec.ts │ │ │ │ └── maskito.spec.ts │ │ │ └── test-setup.ts │ │ └── tsconfig.lib.prod.json │ ├── core/ │ │ ├── README.md │ │ ├── jest.config.ts │ │ ├── package.json │ │ ├── project.json │ │ └── src/ │ │ ├── index.ts │ │ └── lib/ │ │ ├── classes/ │ │ │ ├── index.ts │ │ │ ├── mask-history.ts │ │ │ └── mask-model/ │ │ │ ├── mask-model.ts │ │ │ ├── tests/ │ │ │ │ ├── dynamic-mask.spec.ts │ │ │ │ └── mask-model-fixed-characters.spec.ts │ │ │ └── utils/ │ │ │ ├── apply-overwrite-mode.ts │ │ │ ├── calibrate-value-by-mask.ts │ │ │ ├── get-leading-fixed-characters.ts │ │ │ ├── guess-valid-value-by-pattern.ts │ │ │ ├── guess-valid-value-by-reg-exp.ts │ │ │ ├── is-fixed-character.ts │ │ │ ├── remove-fixed-mask-characters.ts │ │ │ └── validate-value-with-mask.ts │ │ ├── constants/ │ │ │ ├── default-element-predicate.ts │ │ │ ├── default-options.ts │ │ │ └── index.ts │ │ ├── mask.ts │ │ ├── plugins/ │ │ │ ├── broken-prevent-default.plugin.ts │ │ │ ├── change-event-plugin.ts │ │ │ ├── double-space.plugin.ts │ │ │ ├── index.ts │ │ │ ├── initial-calibration-plugin.ts │ │ │ └── strict-composition-plugin.ts │ │ ├── types/ │ │ │ ├── element-predicate.ts │ │ │ ├── element-state.ts │ │ │ ├── index.ts │ │ │ ├── mask-options.ts │ │ │ ├── mask-processors.ts │ │ │ ├── mask.ts │ │ │ ├── maskito-element.ts │ │ │ ├── plugin.ts │ │ │ ├── selection-range.ts │ │ │ └── typed-input-event.ts │ │ └── utils/ │ │ ├── content-editable.ts │ │ ├── dom/ │ │ │ ├── event-listener.ts │ │ │ ├── get-content-editable-selection.ts │ │ │ ├── history-events.ts │ │ │ ├── hotkey.ts │ │ │ ├── set-content-editable-selection.ts │ │ │ └── update-element.ts │ │ ├── element-states-equality.ts │ │ ├── get-line-selection.ts │ │ ├── get-not-empty-selection.ts │ │ ├── get-word-selection.ts │ │ ├── index.ts │ │ ├── pipe.ts │ │ ├── test/ │ │ │ ├── get-not-empty-selection.spec.ts │ │ │ ├── get-word-selection.spec.ts │ │ │ ├── pipe.spec.ts │ │ │ └── transform.spec.ts │ │ └── transform.ts │ ├── demo/ │ │ ├── .gitignore │ │ ├── esbuild-plugins/ │ │ │ ├── maskito-as-taiga-ui-dep.plugin.js │ │ │ └── vue-esm.plugin.js │ │ ├── jest.config.ts │ │ ├── package.json │ │ ├── project.json │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── app.component.html │ │ │ │ ├── app.component.spec.ts │ │ │ │ ├── app.component.ts │ │ │ │ ├── app.config.ts │ │ │ │ ├── app.routes.ts │ │ │ │ ├── app.style.less │ │ │ │ ├── constants/ │ │ │ │ │ ├── demo-path.ts │ │ │ │ │ ├── doc-example-primary-tab.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── modules/ │ │ │ │ │ ├── example-primary-tabs-icons/ │ │ │ │ │ │ ├── angular-logo.component.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── javascript-logo.component.ts │ │ │ │ │ │ ├── react-logo.component.ts │ │ │ │ │ │ └── vue-logo.component.ts │ │ │ │ │ └── logo/ │ │ │ │ │ ├── logo.component.ts │ │ │ │ │ ├── logo.style.less │ │ │ │ │ └── logo.template.html │ │ │ │ ├── server-error-handler.ts │ │ │ │ └── utils/ │ │ │ │ ├── add-default-tabs-processor/ │ │ │ │ │ ├── add-default-tabs-processor.ts │ │ │ │ │ └── default-tabs/ │ │ │ │ │ ├── angular-default-tab.ts │ │ │ │ │ ├── js-default-tab.ts │ │ │ │ │ ├── react-default-tab.ts │ │ │ │ │ └── vue-default-tab.ts │ │ │ │ └── index.ts │ │ │ ├── assets/ │ │ │ │ └── manifest.webmanifest │ │ │ ├── environments/ │ │ │ │ ├── environment.prod.ts │ │ │ │ └── environment.ts │ │ │ ├── index.html │ │ │ ├── main.server.ts │ │ │ ├── main.ts │ │ │ ├── pages/ │ │ │ │ ├── documentation/ │ │ │ │ │ ├── browser-support/ │ │ │ │ │ │ ├── browser-support.component.ts │ │ │ │ │ │ └── browser-support.template.html │ │ │ │ │ ├── core-concepts-overview/ │ │ │ │ │ │ ├── core-concepts-overview.component.ts │ │ │ │ │ │ ├── core-concepts-overview.styles.less │ │ │ │ │ │ ├── core-concepts-overview.template.html │ │ │ │ │ │ └── examples/ │ │ │ │ │ │ └── maskito-public-api-demo.md │ │ │ │ │ ├── element-state/ │ │ │ │ │ │ ├── element-state.component.ts │ │ │ │ │ │ ├── element-state.template.html │ │ │ │ │ │ └── examples/ │ │ │ │ │ │ └── element-state-demo.md │ │ │ │ │ ├── mask-expression/ │ │ │ │ │ │ ├── examples/ │ │ │ │ │ │ │ ├── basic-time-example.md │ │ │ │ │ │ │ ├── dynamic-mask-expression-demo.md │ │ │ │ │ │ │ └── reg-exp-mask-expression-demo.md │ │ │ │ │ │ ├── mask-expression.component.ts │ │ │ │ │ │ └── mask-expression.template.html │ │ │ │ │ ├── maskito-libraries/ │ │ │ │ │ │ ├── maskito-libraries.component.ts │ │ │ │ │ │ └── maskito-libraries.template.html │ │ │ │ │ ├── next-steps/ │ │ │ │ │ │ ├── next-steps.component.ts │ │ │ │ │ │ └── next-steps.template.html │ │ │ │ │ ├── overwrite-mode/ │ │ │ │ │ │ ├── examples/ │ │ │ │ │ │ │ ├── dynamic/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ ├── replace/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ └── shift/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── overwrite-mode.component.ts │ │ │ │ │ │ └── overwrite-mode.template.html │ │ │ │ │ ├── plugins/ │ │ │ │ │ │ ├── examples/ │ │ │ │ │ │ │ ├── 1-initial-calibration/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ ├── index.md │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ ├── 2-strict-composition/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ ├── 3-change-event/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ ├── oversimplified-number-mask.md │ │ │ │ │ │ │ └── pads-zero-plugin.ts │ │ │ │ │ │ ├── plugins.component.ts │ │ │ │ │ │ ├── plugins.style.less │ │ │ │ │ │ └── plugins.template.html │ │ │ │ │ ├── processors/ │ │ │ │ │ │ ├── examples/ │ │ │ │ │ │ │ ├── postprocessor-in-action.md │ │ │ │ │ │ │ ├── preprocessor-first-arg-demo.md │ │ │ │ │ │ │ ├── preprocessor-in-action-demo.md │ │ │ │ │ │ │ └── processor-second-arg-demo.md │ │ │ │ │ │ ├── processors.component.ts │ │ │ │ │ │ └── processors.template.html │ │ │ │ │ ├── real-world-form/ │ │ │ │ │ │ ├── index.html │ │ │ │ │ │ ├── index.less │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── supported-input-types/ │ │ │ │ │ │ ├── examples/ │ │ │ │ │ │ │ ├── password/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ ├── search/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ ├── tel/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ ├── text/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ └── url/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── supported-input-types.component.ts │ │ │ │ │ │ └── supported-input-types.template.html │ │ │ │ │ ├── transformer/ │ │ │ │ │ │ ├── examples/ │ │ │ │ │ │ │ └── utility-in-action-demo.md │ │ │ │ │ │ ├── transformer.component.ts │ │ │ │ │ │ └── transformer.template.html │ │ │ │ │ └── what-is-maskito/ │ │ │ │ │ ├── what-is-maskito.component.ts │ │ │ │ │ ├── what-is-maskito.style.less │ │ │ │ │ └── what-is-maskito.template.html │ │ │ │ ├── frameworks/ │ │ │ │ │ ├── angular/ │ │ │ │ │ │ ├── angular-doc.component.ts │ │ │ │ │ │ ├── angular-doc.template.html │ │ │ │ │ │ └── examples/ │ │ │ │ │ │ ├── 1-nested/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── template.html │ │ │ │ │ │ ├── 2-nested/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── template.html │ │ │ │ │ │ ├── 3-programmatically/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── template.html │ │ │ │ │ │ ├── 4-pipe/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── template.html │ │ │ │ │ │ ├── 5-custom-unmask-handler/ │ │ │ │ │ │ │ ├── index.html │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── unmask.directive.ts │ │ │ │ │ │ ├── 6-pattern/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── template.html │ │ │ │ │ │ ├── basic-directive-approach.md │ │ │ │ │ │ ├── custom-input-example.md │ │ │ │ │ │ └── import-maskito.md │ │ │ │ │ ├── react/ │ │ │ │ │ │ ├── examples/ │ │ │ │ │ │ │ ├── 1-use-maskito-basic-usage/ │ │ │ │ │ │ │ │ ├── example.component.tsx │ │ │ │ │ │ │ │ └── useMaskitoBasicUsage.tsx │ │ │ │ │ │ │ ├── 2-element-predicate/ │ │ │ │ │ │ │ │ ├── awesomeInput.tsx │ │ │ │ │ │ │ │ ├── example.component.tsx │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ ├── 3-merge-ref/ │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ ├── 3-react-hook-form/ │ │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ │ └── with-maskito-register.ts │ │ │ │ │ │ │ ├── best-bad-practice.md │ │ │ │ │ │ │ ├── controlled-input.md │ │ │ │ │ │ │ └── merge-ref.md │ │ │ │ │ │ ├── react-doc.component.ts │ │ │ │ │ │ ├── react-doc.style.less │ │ │ │ │ │ └── react-doc.template.html │ │ │ │ │ └── vue/ │ │ │ │ │ ├── examples/ │ │ │ │ │ │ ├── best-bad-practice.md │ │ │ │ │ │ ├── query-nested-input.md │ │ │ │ │ │ ├── use-maskito-basic-usage.md │ │ │ │ │ │ └── vue-1/ │ │ │ │ │ │ └── component.ts │ │ │ │ │ ├── vue-doc.component.ts │ │ │ │ │ └── vue-doc.template.html │ │ │ │ ├── kit/ │ │ │ │ │ ├── date/ │ │ │ │ │ │ ├── date-mask-doc.component.ts │ │ │ │ │ │ ├── date-mask-doc.style.less │ │ │ │ │ │ ├── date-mask-doc.template.html │ │ │ │ │ │ └── examples/ │ │ │ │ │ │ ├── 1-localization/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── 2-min-max/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ └── maskito-parse-stringify-date-demo.md │ │ │ │ │ ├── date-range/ │ │ │ │ │ │ ├── date-range-mask-doc.component.ts │ │ │ │ │ │ ├── date-range-mask-doc.template.html │ │ │ │ │ │ └── examples/ │ │ │ │ │ │ ├── 1-date-localization/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── 2-min-max/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── 3-min-max-length/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ └── 4-range-separator/ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ └── mask.ts │ │ │ │ │ ├── date-time/ │ │ │ │ │ │ ├── date-time-mask-doc.component.ts │ │ │ │ │ │ ├── date-time-mask-doc.template.html │ │ │ │ │ │ └── examples/ │ │ │ │ │ │ ├── 1-date-time-localization/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── 2-date-time-separator/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── 3-min-max/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── 4-time-step/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── 5-am-pm/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ └── maskito-parse-stringify-date-time-demo.md │ │ │ │ │ ├── number/ │ │ │ │ │ │ ├── examples/ │ │ │ │ │ │ │ ├── 1-high-precision/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ ├── 2-separators/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ ├── 3-postfix/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ ├── 4-decimal-zero-padding/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ ├── 5-custom-minus-sign/ │ │ │ │ │ │ │ │ ├── components.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ ├── 6-minus-before-prefix/ │ │ │ │ │ │ │ │ ├── components.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ ├── 7-dynamic-decimal-zero-padding/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ ├── 8-thousand-separator-pattern/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ └── 9-thousand-separator-pattern-intl/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── helpers/ │ │ │ │ │ │ │ ├── parse-number-as-bigint-type.md │ │ │ │ │ │ │ ├── parse-number-as-number-type.md │ │ │ │ │ │ │ ├── parse-number-invalid-usage.md │ │ │ │ │ │ │ └── stringify-number.md │ │ │ │ │ │ ├── number-mask-doc.component.ts │ │ │ │ │ │ └── number-mask-doc.template.html │ │ │ │ │ ├── plugins/ │ │ │ │ │ │ ├── examples/ │ │ │ │ │ │ │ ├── 1-selection-handler/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ ├── 2-caret-guard/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ ├── 3-event-handlers/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ └── 4-reject/ │ │ │ │ │ │ │ ├── animation.css │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ ├── index.md │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── kit-plugins-doc.component.ts │ │ │ │ │ │ └── kit-plugins-doc.template.html │ │ │ │ │ └── time/ │ │ │ │ │ ├── examples/ │ │ │ │ │ │ ├── 1-modes/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── 2-am-pm/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── 3-step/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── 4-affixes/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── 5-time-segments-min-max/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ └── maskito-parse-stringify-time-demo.md │ │ │ │ │ ├── time-mask-doc.component.ts │ │ │ │ │ ├── time-mask-doc.style.less │ │ │ │ │ └── time-mask-doc.template.html │ │ │ │ ├── pages.ts │ │ │ │ ├── phone/ │ │ │ │ │ ├── examples/ │ │ │ │ │ │ ├── 1-basic/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── 2-validation/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── 3-non-strict/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── 4-lazy-metadata/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── simple.md │ │ │ │ │ │ ├── 5-focus-blur-events/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ └── 6-national-format/ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ └── mask.ts │ │ │ │ │ ├── phone-doc.component.ts │ │ │ │ │ ├── phone-doc.style.less │ │ │ │ │ └── phone-doc.template.html │ │ │ │ ├── recipes/ │ │ │ │ │ ├── card/ │ │ │ │ │ │ ├── card-doc.component.ts │ │ │ │ │ │ ├── card-doc.template.html │ │ │ │ │ │ └── examples/ │ │ │ │ │ │ └── 1-basic/ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ ├── style.less │ │ │ │ │ │ └── template.html │ │ │ │ │ ├── content-editable/ │ │ │ │ │ │ ├── content-editable-doc.component.ts │ │ │ │ │ │ ├── content-editable-doc.template.html │ │ │ │ │ │ └── examples/ │ │ │ │ │ │ ├── 1-time/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── 2-multi-line/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── maskito-with-content-editable.md │ │ │ │ │ │ └── vanilla-js-tab.md │ │ │ │ │ ├── network-address/ │ │ │ │ │ │ ├── examples/ │ │ │ │ │ │ │ ├── 1-ipv6/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ ├── 2-ipv4/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ └── 3-mac/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── network-address-doc.component.ts │ │ │ │ │ │ └── network-address-doc.template.html │ │ │ │ │ ├── phone/ │ │ │ │ │ │ ├── examples/ │ │ │ │ │ │ │ ├── 1-us-phone/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ └── 2-kz-phone/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ ├── mask.ts │ │ │ │ │ │ │ └── template.html │ │ │ │ │ │ ├── phone-doc.component.ts │ │ │ │ │ │ └── phone-doc.template.html │ │ │ │ │ ├── placeholder/ │ │ │ │ │ │ ├── examples/ │ │ │ │ │ │ │ ├── 1-cvc-code/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ ├── 2-phone/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ └── 3-date/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── placeholder-doc.component.ts │ │ │ │ │ │ └── placeholder-doc.template.html │ │ │ │ │ ├── postfix/ │ │ │ │ │ │ ├── examples/ │ │ │ │ │ │ │ ├── 1-pattern-mask/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ └── 2-postprocessor/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── postfix-doc.component.ts │ │ │ │ │ │ └── postfix-doc.template.html │ │ │ │ │ ├── prefix/ │ │ │ │ │ │ ├── examples/ │ │ │ │ │ │ │ ├── 1-pattern-mask/ │ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ │ └── 2-postprocessor/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ ├── prefix-doc.component.ts │ │ │ │ │ │ └── prefix-doc.template.html │ │ │ │ │ └── textarea/ │ │ │ │ │ ├── examples/ │ │ │ │ │ │ ├── 1-latin/ │ │ │ │ │ │ │ ├── component.ts │ │ │ │ │ │ │ └── mask.ts │ │ │ │ │ │ └── maskito-with-textarea.md │ │ │ │ │ ├── textarea-doc.component.ts │ │ │ │ │ └── textarea-doc.template.html │ │ │ │ └── stackblitz/ │ │ │ │ ├── components/ │ │ │ │ │ ├── stackblitz-edit-button/ │ │ │ │ │ │ ├── stackblitz-edit-button.component.ts │ │ │ │ │ │ └── stackblitz-edit-button.style.less │ │ │ │ │ └── stackblitz-starter/ │ │ │ │ │ ├── stackblitz-starter.component.ts │ │ │ │ │ └── stackblitz-starter.style.less │ │ │ │ ├── files/ │ │ │ │ │ ├── example.ts.md │ │ │ │ │ ├── starter.ts.md │ │ │ │ │ └── styles.css │ │ │ │ ├── index.ts │ │ │ │ └── stackblitz.service.ts │ │ │ ├── server.ts │ │ │ ├── styles.less │ │ │ ├── test-setup.ts │ │ │ └── typings.d.ts │ │ ├── tsconfig.app.json │ │ └── tsconfig.typecheck.json │ ├── demo-integrations/ │ │ ├── cypress-react.config.ts │ │ ├── cypress.config.ts │ │ ├── package.json │ │ ├── project.json │ │ ├── src/ │ │ │ ├── fixtures/ │ │ │ │ └── example.json │ │ │ ├── plugins/ │ │ │ │ └── index.js │ │ │ ├── support/ │ │ │ │ ├── assertions/ │ │ │ │ │ ├── have-ng-control-value.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── paste.ts │ │ │ │ │ └── smart-tick.ts │ │ │ │ ├── component-index.html │ │ │ │ ├── component-react.ts │ │ │ │ ├── component.ts │ │ │ │ ├── constants/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── real-events-support.ts │ │ │ │ └── e2e.ts │ │ │ └── tests/ │ │ │ ├── addons/ │ │ │ │ └── phone/ │ │ │ │ ├── phone-basic.cy.ts │ │ │ │ ├── phone-national-trunk-prefix.cy.ts │ │ │ │ ├── phone-non-strict.cy.ts │ │ │ │ ├── phone-separator.cy.ts │ │ │ │ └── phone-strict.cy.ts │ │ │ ├── angular/ │ │ │ │ ├── form-control-changes.cy.ts │ │ │ │ └── unmask-handler.cy.ts │ │ │ ├── component-testing/ │ │ │ │ ├── angular/ │ │ │ │ │ ├── disable-mask-on-null.cy.ts │ │ │ │ │ └── pattern.cy.ts │ │ │ │ ├── angular-predicate/ │ │ │ │ │ ├── angular-predicate.cy.ts │ │ │ │ │ └── multi-test.component.ts │ │ │ │ ├── change-event-plugin/ │ │ │ │ │ └── change-event-plugin.cy.ts │ │ │ │ ├── initial-calibration-plugin/ │ │ │ │ │ └── dispatch-event.cy.ts │ │ │ │ ├── multi-character-date-segment-separator/ │ │ │ │ │ └── multi-character-date-segment-separator.cy.ts │ │ │ │ ├── native-max-length/ │ │ │ │ │ └── native-maxlength-attribute.cy.ts │ │ │ │ ├── native-select-method/ │ │ │ │ │ └── native-select-method.cy.ts │ │ │ │ ├── number/ │ │ │ │ │ ├── alone-decimal-separator.cy.ts │ │ │ │ │ ├── min-max-bigint.cy.ts │ │ │ │ │ ├── mirrored-prefix-postfix.cy.ts │ │ │ │ │ ├── mirrored-value-postfix.cy.ts │ │ │ │ │ ├── multi-character-prefix.cy.ts │ │ │ │ │ ├── overwrite-selection-range.cy.ts │ │ │ │ │ ├── postfix-multi-character.cy.ts │ │ │ │ │ ├── postfix-with-point.cy.ts │ │ │ │ │ ├── runtime-postfix-changes/ │ │ │ │ │ │ ├── runtime-postfix-changes.cy.ts │ │ │ │ │ │ └── sandbox.component.ts │ │ │ │ │ └── with-initial-value.cy.ts │ │ │ │ ├── overwrite-mode/ │ │ │ │ │ └── overwrite-mode-replace.cy.ts │ │ │ │ ├── paste/ │ │ │ │ │ └── cy-paste-utility.cy.ts │ │ │ │ ├── phone/ │ │ │ │ │ ├── phone-national-format.cy.ts │ │ │ │ │ └── phone-with-initial-value.cy.ts │ │ │ │ ├── placeholder/ │ │ │ │ │ ├── placeholder-dispatch-input-events.cy.ts │ │ │ │ │ ├── placeholder-has-same-characters-as-textfield.cy.ts │ │ │ │ │ └── placeholder-partial-removal-on-blur.cy.ts │ │ │ │ ├── react/ │ │ │ │ │ ├── async-predicate-options-race/ │ │ │ │ │ │ ├── reactApp.tsx │ │ │ │ │ │ └── reactAsyncPredicateOptionsRace.cy.tsx │ │ │ │ │ ├── async-predicates-race/ │ │ │ │ │ │ ├── reactApp.tsx │ │ │ │ │ │ └── reactAsyncPredicatesRace.cy.tsx │ │ │ │ │ ├── awesomeInput.tsx │ │ │ │ │ └── change-event/ │ │ │ │ │ └── changeEvent.cy.tsx │ │ │ │ └── utils.ts │ │ │ ├── kit/ │ │ │ │ ├── date/ │ │ │ │ │ ├── date-basic.cy.ts │ │ │ │ │ ├── date-fullwidth-to-halfwidth.cy.ts │ │ │ │ │ ├── date-min-max.cy.ts │ │ │ │ │ ├── date-mode.cy.ts │ │ │ │ │ ├── date-segments-zero-padding.cy.ts │ │ │ │ │ └── date-separator.cy.ts │ │ │ │ ├── date-range/ │ │ │ │ │ ├── date-range-basic.cy.ts │ │ │ │ │ ├── date-range-custom-range-separator.cy.ts │ │ │ │ │ ├── date-range-fullwidth-to-halfwidth.cy.ts │ │ │ │ │ ├── date-range-min-max-length.cy.ts │ │ │ │ │ ├── date-range-min-max.cy.ts │ │ │ │ │ ├── date-range-mode.cy.ts │ │ │ │ │ ├── date-range-segments-zero-padding.cy.ts │ │ │ │ │ └── date-range-separator.cy.ts │ │ │ │ ├── date-time/ │ │ │ │ │ ├── date-time-basic.cy.ts │ │ │ │ │ ├── date-time-date-time-separator.cy.ts │ │ │ │ │ ├── date-time-fullwidth-to-halfwidth.cy.ts │ │ │ │ │ ├── date-time-meridiem.cy.ts │ │ │ │ │ ├── date-time-min-max.cy.ts │ │ │ │ │ ├── date-time-mode.cy.ts │ │ │ │ │ ├── date-time-separator.cy.ts │ │ │ │ │ └── date-time-time-step.cy.ts │ │ │ │ ├── number/ │ │ │ │ │ ├── number-basic.cy.ts │ │ │ │ │ ├── number-bigint.cy.ts │ │ │ │ │ ├── number-decimal-separator.cy.ts │ │ │ │ │ ├── number-decimal-zero-padding.cy.ts │ │ │ │ │ ├── number-examples.cy.ts │ │ │ │ │ ├── number-fullwidth-to-halfwidth.cy.ts │ │ │ │ │ ├── number-max-validation.cy.ts │ │ │ │ │ ├── number-min-validation.cy.ts │ │ │ │ │ ├── number-minus-before-prefix.cy.ts │ │ │ │ │ ├── number-minus-sign.cy.ts │ │ │ │ │ ├── number-precision.cy.ts │ │ │ │ │ ├── number-prefix-postfix.cy.ts │ │ │ │ │ ├── number-thousand-separator.cy.ts │ │ │ │ │ ├── number-zero-integer-part.cy.ts │ │ │ │ │ └── utils.ts │ │ │ │ └── time/ │ │ │ │ ├── time-affixes.cy.ts │ │ │ │ ├── time-basic.cy.ts │ │ │ │ ├── time-fullwidth-to-halfwidth.cy.ts │ │ │ │ ├── time-meridiem.cy.ts │ │ │ │ ├── time-mode.cy.ts │ │ │ │ ├── time-segment-max-values.cy.ts │ │ │ │ └── time-step.cy.ts │ │ │ ├── react/ │ │ │ │ └── element-predicate.cy.ts │ │ │ ├── recipes/ │ │ │ │ ├── card/ │ │ │ │ │ └── card.cy.ts │ │ │ │ ├── content-editable/ │ │ │ │ │ ├── multi-line.cy.ts │ │ │ │ │ └── single-line-time-mask.cy.ts │ │ │ │ ├── network-address/ │ │ │ │ │ ├── ipv4.cy.ts │ │ │ │ │ ├── ipv6.cy.ts │ │ │ │ │ └── mac.cy.ts │ │ │ │ ├── phone/ │ │ │ │ │ └── phone.cy.ts │ │ │ │ ├── placeholder/ │ │ │ │ │ ├── date.cy.ts │ │ │ │ │ ├── us-phone.cy.ts │ │ │ │ │ └── сvc-code.cy.ts │ │ │ │ ├── plugins/ │ │ │ │ │ └── reject.cy.ts │ │ │ │ ├── postfix/ │ │ │ │ │ ├── percentage.cy.ts │ │ │ │ │ └── postprocessor.cy.ts │ │ │ │ ├── prefix/ │ │ │ │ │ ├── dynamic-pattern-mask-expression.cy.ts │ │ │ │ │ └── postprocessor.cy.ts │ │ │ │ └── textarea/ │ │ │ │ └── textarea-latin-letters-digits.cy.ts │ │ │ ├── ssr/ │ │ │ │ └── ssr.cy.ts │ │ │ ├── utils.ts │ │ │ └── vue/ │ │ │ └── vue.cy.ts │ │ ├── tsconfig.json │ │ └── vite.config.ts │ ├── kit/ │ │ ├── README.md │ │ ├── jest.config.ts │ │ ├── package.json │ │ ├── project.json │ │ └── src/ │ │ ├── index.ts │ │ └── lib/ │ │ ├── constants/ │ │ │ ├── date-segment-max-values.ts │ │ │ ├── default-decimal-pseudo-separators.ts │ │ │ ├── default-min-max-dates.ts │ │ │ ├── default-pseudo-minuses.ts │ │ │ ├── default-time-segment-bounds.ts │ │ │ ├── index.ts │ │ │ ├── meridiem.ts │ │ │ ├── time-fixed-characters.ts │ │ │ ├── time-segment-value-lengths.ts │ │ │ └── unicode-characters.ts │ │ ├── masks/ │ │ │ ├── date/ │ │ │ │ ├── date-mask.ts │ │ │ │ ├── date-params.ts │ │ │ │ ├── index.ts │ │ │ │ ├── tests/ │ │ │ │ │ └── date-mask.spec.ts │ │ │ │ └── utils/ │ │ │ │ ├── index.ts │ │ │ │ ├── parse-date.ts │ │ │ │ ├── stringify-date.ts │ │ │ │ ├── tests/ │ │ │ │ │ ├── parse-date.spec.ts │ │ │ │ │ └── stringify-date.spec.ts │ │ │ │ └── to-date-segments.ts │ │ │ ├── date-range/ │ │ │ │ ├── constants.ts │ │ │ │ ├── date-range-mask.ts │ │ │ │ ├── index.ts │ │ │ │ ├── processors/ │ │ │ │ │ ├── min-max-range-length-postprocessor.ts │ │ │ │ │ └── swap-dates-postprocessor.ts │ │ │ │ └── tests/ │ │ │ │ ├── date-segments-zero-padding.spec.ts │ │ │ │ └── pseudo-range-separators.spec.ts │ │ │ ├── date-time/ │ │ │ │ ├── constants/ │ │ │ │ │ ├── date-time-separator.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── date-time-mask.ts │ │ │ │ ├── date-time-params.ts │ │ │ │ ├── index.ts │ │ │ │ ├── postprocessors/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── min-max-date-time-postprocessor.ts │ │ │ │ ├── preprocessors/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── valid-date-time-preprocessor.ts │ │ │ │ ├── tests/ │ │ │ │ │ ├── date-segments-zero-padding.spec.ts │ │ │ │ │ ├── date-time-separator.spec.ts │ │ │ │ │ └── pseudo-date-end-separator.spec.ts │ │ │ │ └── utils/ │ │ │ │ ├── index.ts │ │ │ │ ├── is-date-time-string-complete.ts │ │ │ │ ├── parse-date-time.ts │ │ │ │ ├── split-date-time-string.ts │ │ │ │ ├── stringify-date-time.ts │ │ │ │ └── tests/ │ │ │ │ ├── parse-date-time.spec.ts │ │ │ │ ├── split-date-time-string.spec.ts │ │ │ │ └── stringify-date-time.spec.ts │ │ │ ├── number/ │ │ │ │ ├── index.ts │ │ │ │ ├── number-mask.ts │ │ │ │ ├── number-params.ts │ │ │ │ ├── plugins/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── leading-zeroes-validation.plugin.ts │ │ │ │ │ ├── min-max.plugin.ts │ │ │ │ │ └── not-empty-integer.plugin.ts │ │ │ │ ├── processors/ │ │ │ │ │ ├── affixes-filter-preprocessor.ts │ │ │ │ │ ├── decimal-zero-padding-postprocessor.ts │ │ │ │ │ ├── empty-postprocessor.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── initialization-only-preprocessor.ts │ │ │ │ │ ├── leading-minus-deletion-preprocessor.ts │ │ │ │ │ ├── leading-zeroes-validation-postprocessor.ts │ │ │ │ │ ├── min-max-postprocessor.ts │ │ │ │ │ ├── non-removable-chars-deletion-preprocessor.ts │ │ │ │ │ ├── not-empty-integer-part-preprocessor.ts │ │ │ │ │ ├── number-prefix-postprocessor.ts │ │ │ │ │ ├── pseudo-character-preprocessor.ts │ │ │ │ │ ├── repeated-decimal-separator-preprocessor.ts │ │ │ │ │ ├── tests/ │ │ │ │ │ │ ├── leading-zeroes-validation-postprocessor.spec.ts │ │ │ │ │ │ └── not-empty-integer-part-preprocessor.spec.ts │ │ │ │ │ ├── thousand-separator-postprocessor.ts │ │ │ │ │ └── zero-precision-preprocessor.ts │ │ │ │ ├── tests/ │ │ │ │ │ └── number-mask.spec.ts │ │ │ │ └── utils/ │ │ │ │ ├── extract-affixes.ts │ │ │ │ ├── generate-mask-expression.ts │ │ │ │ ├── index.ts │ │ │ │ ├── number-parts.ts │ │ │ │ ├── parse-number.ts │ │ │ │ ├── stringify-number-without-exp.ts │ │ │ │ ├── stringify-number.ts │ │ │ │ ├── tests/ │ │ │ │ │ ├── parse-number.spec.ts │ │ │ │ │ ├── stringify-number-without-exp.spec.ts │ │ │ │ │ ├── stringify-number.spec.ts │ │ │ │ │ ├── to-number-parts.spec.ts │ │ │ │ │ └── validate-decimal-pseudo-separators.spec.ts │ │ │ │ ├── validate-decimal-pseudo-separators.ts │ │ │ │ └── with-number-defaults.ts │ │ │ └── time/ │ │ │ ├── index.ts │ │ │ ├── time-mask.ts │ │ │ ├── time-params.ts │ │ │ └── utils/ │ │ │ ├── index.ts │ │ │ ├── parse-time.ts │ │ │ ├── stringify-time.ts │ │ │ └── tests/ │ │ │ ├── parse-time.spec.ts │ │ │ └── stringify-time.spec.ts │ │ ├── plugins/ │ │ │ ├── add-on-focus.ts │ │ │ ├── caret-guard.ts │ │ │ ├── event-handler.ts │ │ │ ├── index.ts │ │ │ ├── reject-event.ts │ │ │ ├── remove-on-blur.ts │ │ │ ├── selection-change.ts │ │ │ └── time/ │ │ │ ├── meridiem-stepping.ts │ │ │ └── time-segments-stepping.ts │ │ ├── processors/ │ │ │ ├── colon-convert-preprocessor.ts │ │ │ ├── date-segments-zero-padding-postprocessor.ts │ │ │ ├── first-date-end-separator-preprocessor.ts │ │ │ ├── fullwidth-to-halfwidth-preprocessor.ts │ │ │ ├── index.ts │ │ │ ├── invalid-time-segment-insertion-preprocessor.ts │ │ │ ├── meridiem-processors.ts │ │ │ ├── min-max-date-postprocessor.ts │ │ │ ├── normalize-date-preprocessor.ts │ │ │ ├── postfix-postprocessor.ts │ │ │ ├── prefix-postprocessor.ts │ │ │ ├── tests/ │ │ │ │ ├── first-date-end-separator-preprocessor.spec.ts │ │ │ │ ├── normalize-date-preprocessor.spec.ts │ │ │ │ ├── postfix-postprocessor.spec.ts │ │ │ │ ├── prefix-postprocessor.spec.ts │ │ │ │ ├── valid-date-preprocessor.spec.ts │ │ │ │ └── with-placeholder.spec.ts │ │ │ ├── valid-date-preprocessor.ts │ │ │ ├── with-placeholder.ts │ │ │ └── zero-placeholders-preprocessor.ts │ │ ├── types/ │ │ │ ├── date-mode.ts │ │ │ ├── date-segments.ts │ │ │ ├── index.ts │ │ │ ├── time-mode.ts │ │ │ └── time-segments.ts │ │ └── utils/ │ │ ├── clamp.ts │ │ ├── count-digits.ts │ │ ├── date/ │ │ │ ├── append-date.ts │ │ │ ├── date-segment-value-length.ts │ │ │ ├── date-to-segments.ts │ │ │ ├── get-date-segments-order.ts │ │ │ ├── get-first-complete-date.ts │ │ │ ├── is-date-string-complete.ts │ │ │ ├── parse-date-range-string.ts │ │ │ ├── parse-date-string.ts │ │ │ ├── raise-segment-value-to-min.ts │ │ │ ├── segments-to-date.ts │ │ │ ├── tests/ │ │ │ │ ├── append-date.spec.ts │ │ │ │ ├── get-date-segment-value-length.spec.ts │ │ │ │ └── parse-date-range-string.spec.ts │ │ │ ├── to-date-string.ts │ │ │ └── validate-date-string.ts │ │ ├── dummy.ts │ │ ├── escape-reg-exp.ts │ │ ├── find-common-beginning-substr.ts │ │ ├── index.ts │ │ ├── is-empty.ts │ │ ├── pad-with-zeroes-until-valid.ts │ │ ├── tests/ │ │ │ ├── clamp.spec.ts │ │ │ ├── escape-reg-exp.spec.ts │ │ │ ├── find-common-beginning-substr.spec.ts │ │ │ ├── get-first-complete-date.spec.ts │ │ │ ├── is-empty.spec.ts │ │ │ ├── to-half-width-colon.spec.ts │ │ │ └── to-half-width-number.spec.ts │ │ ├── time/ │ │ │ ├── create-time-mask-expression.ts │ │ │ ├── enrich-time-segments-with-zeroes.ts │ │ │ ├── index.ts │ │ │ ├── pad-end-time-segments.ts │ │ │ ├── pad-start-time-segments.ts │ │ │ ├── pad-time-segments.ts │ │ │ ├── parse-time-string.ts │ │ │ ├── tests/ │ │ │ │ ├── enrich-time-segments-with-zeroes.spec.ts │ │ │ │ ├── parse-time-string.spec.ts │ │ │ │ └── to-time-string.spec.ts │ │ │ └── to-time-string.ts │ │ ├── to-half-width-colon.ts │ │ └── to-half-width-number.ts │ ├── phone/ │ │ ├── README.md │ │ ├── jest.config.ts │ │ ├── package.json │ │ ├── project.json │ │ └── src/ │ │ ├── index.ts │ │ └── lib/ │ │ └── masks/ │ │ ├── index.ts │ │ └── phone/ │ │ ├── constants/ │ │ │ ├── index.ts │ │ │ └── template-filler.ts │ │ ├── index.ts │ │ ├── phone-mask-non-strict.ts │ │ ├── phone-mask-strict.ts │ │ ├── phone-mask.ts │ │ ├── processors/ │ │ │ ├── browser-autofill-preprocessor.ts │ │ │ ├── cut-init-country-code-preprocessor.ts │ │ │ ├── index.ts │ │ │ ├── paste-non-strict-phone-preprocessor.ts │ │ │ ├── paste-strict-phone-preprocessor.ts │ │ │ ├── phone-length-postprocessor.ts │ │ │ └── sanitize-phone-preprocessor.ts │ │ ├── tests/ │ │ │ ├── get-phone-template.spec.ts │ │ │ └── phone-mask.spec.ts │ │ └── utils/ │ │ ├── cut-phone-by-valid-length.ts │ │ ├── generate-phone-mask.ts │ │ ├── get-country-from-number.ts │ │ ├── get-phone-template.ts │ │ ├── index.ts │ │ └── select-template.ts │ ├── react/ │ │ ├── .babelrc │ │ ├── README.md │ │ ├── jest.config.ts │ │ ├── package.json │ │ ├── project.json │ │ └── src/ │ │ ├── index.ts │ │ └── lib/ │ │ ├── adaptControlledElement.tsx │ │ ├── tests/ │ │ │ ├── controlledInput.spec.tsx │ │ │ ├── elementPredicate.spec.tsx │ │ │ └── useMaskito.spec.tsx │ │ ├── useIsomorphicLayoutEffect.tsx │ │ └── useMaskito.tsx │ └── vue/ │ ├── README.md │ ├── jest.config.ts │ ├── package.json │ ├── project.json │ └── src/ │ ├── index.ts │ └── lib/ │ ├── maskito.spec.ts │ └── maskito.ts ├── tsconfig.build.json ├── tsconfig.json └── tsconfig.spec.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cspell.json ================================================ { "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/master/cspell.schema.json", "import": ["@taiga-ui/cspell-config"], "files": ["*/*.*"], "ignorePaths": [ ".git", ".gitignore", ".npmrc", ".cspell.json", "**/dist/**", "**/assets/**", "**/node_modules/**", "**/demo/server.ts", "**/demo-integrations/src/tests/**", "*.{log,svg,snap,png,ogv,yml}", "**/package.json", "**/tsconfig*.json" ], "ignoreWords": ["Acpekt", "WHATWG", "prebundle"] } ================================================ FILE: .editorconfig ================================================ # Editor configuration, see https://editorconfig.org root = true [*] charset = utf-8 indent_style = space indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true [*.md] max_line_length = off trim_trailing_whitespace = false ================================================ FILE: .firebaserc ================================================ { "projects": { "default": "maskito" } } ================================================ FILE: .github/CODEOWNERS ================================================ * @taiga-family/core-team ================================================ FILE: .github/CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: - Using welcoming and inclusive language - Being respectful of differing viewpoints and experiences - Gracefully accepting constructive criticism - Focusing on what is best for the community - Showing empathy towards other community members Examples of unacceptable behavior by participants include: - The use of sexualized language or imagery and unwelcome sexual attention or advances - Trolling, insulting/derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or electronic address, without explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project at opensource@acpekt.ru. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq ================================================ FILE: .github/CONTRIBUTING.md ================================================ # Contributing > Thank you for considering contributing to our project. Your help if very welcome! When contributing, it's better to first discuss the change you wish to make via issue, email, or any other method with the owners of this repository before making a change. All members of our community are expected to follow our [Code of Conduct](.github/CODE_OF_CONDUCT.md). Please make sure you are welcoming and friendly in all of our spaces. ## Getting started In order to make your contribution please make a fork of the repository. After you've pulled the code, follow these steps to kick-start the development: 1. Run `npm ci` to install dependencies 2. Run `npm start` to launch demo project where you could test your changes 3. Use following commands to ensure code quality ```bash npm run lint npm run build npm run test npm run cy:run ``` ## Pull Request Process 1. We follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0-beta.4/) in our commit messages, i.e. `feat(core): improve typing` 2. Update [demo](projects/demo) application to reflect changes related to public API and everything relevant 3. Make sure you cover all code changes with unit tests and/or [Cypress](https://www.cypress.io) tests 4. When you are ready, create Pull Request of your fork into original repository ================================================ FILE: .github/ISSUE_TEMPLATE/1-bug-report.yml ================================================ name: '🐞 - Bug Report' title: '🐞 - ' description: Report a bug in the Maskito labels: ['bug'] type: Bug body: - type: dropdown id: affected-packages attributes: label: Which package(s) are the source of the bug? options: - '@maskito/core' - '@maskito/kit' - '@maskito/phone' - '@maskito/angular' - '@maskito/react' - '@maskito/vue' - Don't known / other multiple: true validations: required: true - type: input id: playground-link attributes: label: Playground Link description: | Link to an isolated reproduction in our [StackBlitz playground](https://maskito.dev/stackblitz). If either of the following holds true: - You can't reproduce the issue in the playground - Your issue requires some complex setup - such as multiple files or a specific folder structure. So you can use any link that might help for reproduction bug: github repo, demo url, etc. ***Help us to help you!*** placeholder: https://stackblitz.com/edit/... validations: required: true - type: textarea id: description attributes: label: Description placeholder: | Please provide the exception or error you saw. How do you trigger this bug? Please walk us through it step by step. Please provide a screenshot if possible. validations: required: true - type: input id: maskito-version attributes: label: Maskito version placeholder: x.y.z validations: required: true - type: checkboxes id: browser-specific attributes: label: Which browsers have you used? description: You may select more than one. options: - label: Chrome - label: Firefox - label: Safari - label: Edge - type: checkboxes id: operating-systems attributes: label: Which operating systems have you used? description: You may select more than one. options: - label: macOS - label: Windows - label: Linux - label: iOS - label: Android ================================================ FILE: .github/ISSUE_TEMPLATE/2-feature-request.yml ================================================ name: '🚀 - Feature Request' title: '🚀 - ' description: Suggest a feature for Maskito labels: ['feature'] type: Feature body: - type: dropdown id: affected-packages attributes: label: Which package(s) are relevant/related to the feature request? options: - '@maskito/core' - '@maskito/kit' - '@maskito/phone' - '@maskito/angular' - '@maskito/react' - '@maskito/vue' - Don't known / other multiple: true - type: textarea id: description attributes: label: Description placeholder: | Proposed solution. Alternatives considered. validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/3-documentation.yml ================================================ name: '📚 - Documentation' title: '📚 - ' description: Report an issue in Maskito's documentation labels: ['documentation'] type: Documentation body: - type: input id: affected-url attributes: label: What is the affected URL? - type: textarea id: description attributes: label: Description placeholder: | How do you trigger this bug? Please walk us through it step by step. Please provide a screenshot if possible. validations: required: true - type: checkboxes id: browser-specific attributes: label: Which browsers have you used? description: You may select more than one. options: - label: Chrome - label: Firefox - label: Safari - label: Edge - type: checkboxes id: operating-systems attributes: label: Which operating systems have you used? description: You may select more than one. options: - label: macOS - label: Windows - label: Linux - label: iOS - label: Android ================================================ FILE: .github/renovate.json ================================================ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["github>taiga-family/renovate-config"], "packageRules": [ { "enabled": false, "matchPackageNames": [ "/^@nx.*/", "/^nx$/", "cypress" ] }, { "enabled": false, "matchPackageNames": ["jest-preset-angular"] } ] } ================================================ FILE: .github/workflows/assign-author.yml ================================================ name: 🤖 PR author as an assignee on: pull_request: types: [opened] jobs: assign: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 - uses: taiga-family/ci/actions/setup/variables@v1.201.0 - uses: toshimaru/auto-author-assign@v3.0.1 continue-on-error: true concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true ================================================ FILE: .github/workflows/auto-merge.yml ================================================ name: 🤖 PR auto merge on: pull_request: env: PR_JOBS_NAME: '[ "Packages", "Demo", "Firebase", "Lint result", "tests", "E2E result" ]' jobs: setup: runs-on: ubuntu-latest outputs: matrix: ${{ steps.matrix.outputs.value }} steps: - uses: actions/checkout@v6.0.2 - uses: taiga-family/ci/actions/setup/variables@v1.201.0 - id: matrix run: echo "value=$PR_JOBS_NAME" >> $GITHUB_OUTPUT wait: needs: [setup] runs-on: ubuntu-latest strategy: fail-fast: true matrix: value: ${{ fromJSON(needs.setup.outputs.matrix) }} steps: - uses: taiga-family/ci/actions/run/wait-job@v1.201.0 with: token: ${{ secrets.GITHUB_TOKEN }} job: ${{ matrix.value }} approve: needs: [wait] runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 - uses: taiga-family/ci/actions/setup/variables@v1.201.0 - uses: taiga-family/ci/actions/auto/approve/double@v1.201.0 if: env.IS_TAIGA_FAMILY_BOT_PR_AUTHOR == 'true' with: token1: ${{ secrets.GITHUB_TOKEN }} token2: ${{ secrets.TAIGA_FAMILY_APPROVE_BOT_PAT }} - uses: taiga-family/ci/actions/run/merge@v1.201.0 if: env.IS_TAIGA_FAMILY_BOT_PR_AUTHOR == 'true' with: token: ${{ secrets.TAIGA_FAMILY_BOT_PAT }} concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true ================================================ FILE: .github/workflows/build.yml ================================================ name: Build on: pull_request: push: branches: [main] jobs: build-packages: name: Packages runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 - uses: taiga-family/ci/actions/setup/variables@v1.201.0 - uses: taiga-family/ci/actions/setup/node@v1.201.0 - run: npx nx run-many --target build --all --exclude=demo build-demo: name: Demo runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 - uses: taiga-family/ci/actions/setup/variables@v1.201.0 - uses: taiga-family/ci/actions/setup/node@v1.201.0 - run: npx nx build-gh-pages concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true ================================================ FILE: .github/workflows/deploy-preview.yml ================================================ name: Deploy / preview on: pull_request jobs: build_and_preview: name: Firebase runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 - uses: taiga-family/ci/actions/setup/variables@v1.201.0 - uses: taiga-family/ci/actions/setup/node@v1.201.0 - run: npx nx run-many --target build --all --exclude=demo - run: npx nx run demo:build:typecheck - name: Debug output run: tree dist/demo/browser -P '*.html' - name: Deploy preview uses: FirebaseExtended/action-hosting-deploy@v0.10.0 if: env.IS_OWNER_MODE == 'true' with: repoToken: ${{ secrets.GITHUB_TOKEN }} firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_MASKITO }} projectId: maskito expires: 1d concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true ================================================ FILE: .github/workflows/deploy.yml ================================================ name: Deploy on: workflow_dispatch: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest if: ${{ github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, 'chore(release)') }} steps: - uses: actions/checkout@v6.0.2 - uses: taiga-family/ci/actions/setup/variables@v1.201.0 - uses: taiga-family/ci/actions/setup/node@v1.201.0 - run: npx nx build-gh-pages - uses: JamesIves/github-pages-deploy-action@v4.8.0 with: branch: gh-pages folder: dist/demo/browser silent: false clean: true concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true ================================================ FILE: .github/workflows/e2e.yml ================================================ name: E2E on: [pull_request] env: CACHE_DIST_KEY: dist-${{ github.ref }}-${{ github.sha }} CYPRESS_CACHE_FOLDER: ./node_modules/cache-cypress UNIVERSAL_SERVER: http://localhost:4000 STATIC_SERVER: http://localhost:8080 jobs: build-demo: name: Build demo runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 with: fetch-depth: 10 - uses: taiga-family/ci/actions/setup/variables@v1.201.0 - uses: taiga-family/ci/actions/setup/node@v1.201.0 - run: npx tsc --project projects/demo-integrations/tsconfig.json - name: Mark demo-app directory for persist in cache uses: actions/cache@v5 with: path: dist/demo key: ${{ env.CACHE_DIST_KEY }} - name: Build demo # --optimization false to keep `window.ng` inside Cypress tests run: npm run build -- --optimization false e2e-kit: needs: [build-demo] runs-on: ubuntu-latest strategy: fail-fast: false matrix: project: [date, date-range, date-time, number, time] name: Kit / ${{ matrix.project }} steps: - uses: actions/checkout@v6.0.2 - uses: taiga-family/ci/actions/setup/variables@v1.201.0 - uses: taiga-family/ci/actions/setup/node@v1.201.0 - uses: taiga-family/ci/actions/setup/cypress@v1.201.0 - name: Download demo build from cache uses: actions/cache@v5 with: path: dist/demo key: ${{ env.CACHE_DIST_KEY }} - name: Serving SSR server run: | npm run serve:ssr -- --ci & sleep 5 curl -X GET -I -f "${{ env.UNIVERSAL_SERVER }}" - name: Run Cypress tests run: npx nx e2e demo-integrations --spec="**/kit/${{ matrix.project }}/**/*.cy.ts" --baseUrl=${{ env.UNIVERSAL_SERVER}} e2e-recipes: needs: [build-demo] runs-on: ubuntu-latest name: Recipes steps: - uses: actions/checkout@v6.0.2 - uses: taiga-family/ci/actions/setup/variables@v1.201.0 - uses: taiga-family/ci/actions/setup/node@v1.201.0 - uses: taiga-family/ci/actions/setup/cypress@v1.201.0 - name: Download demo build from cache uses: actions/cache@v5 with: path: dist/demo key: ${{ env.CACHE_DIST_KEY }} - name: Serving SSR server run: | npm run serve:ssr -- --ci & sleep 5 curl -X GET -I -f "${{ env.UNIVERSAL_SERVER }}" - name: Run Cypress tests run: npx nx e2e demo-integrations --spec="**/recipes/**/*.cy.ts" --baseUrl=${{ env.UNIVERSAL_SERVER}} e2e-others: needs: [build-demo] runs-on: ubuntu-latest name: Others steps: - uses: actions/checkout@v6.0.2 - uses: taiga-family/ci/actions/setup/variables@v1.201.0 - uses: taiga-family/ci/actions/setup/node@v1.201.0 - uses: taiga-family/ci/actions/setup/cypress@v1.201.0 - name: Download demo build from cache uses: actions/cache@v5 with: path: dist/demo key: ${{ env.CACHE_DIST_KEY }} - name: Serving SSR server run: | npm run serve:ssr -- --ci & sleep 5 curl -X GET -I -f "${{ env.UNIVERSAL_SERVER }}" - name: Run Cypress tests # Replace with npm run cy:run -- --spec "**/!(kit|recipes)/*.cy.ts" --config baseUrl="${{ env.UNIVERSAL_SERVER }}" # After this issue fix: https://github.com/cypress-io/cypress/issues/22407 run: npx nx e2e demo-integrations --spec="**/(angular|react|ssr|addons|others)/**/*.cy.ts" --baseUrl=${{ env.UNIVERSAL_SERVER}} component-testing: runs-on: ubuntu-latest name: Component Testing steps: - uses: actions/checkout@v6.0.2 - uses: taiga-family/ci/actions/setup/variables@v1.201.0 - uses: taiga-family/ci/actions/setup/node@v1.201.0 - uses: taiga-family/ci/actions/setup/cypress@v1.201.0 - name: Run Cypress tests run: npx nx component-test demo-integrations --browser=chrome && npx nx ct-react demo-integrations --browser=chrome result: needs: [build-demo, e2e-kit, e2e-recipes, e2e-others, component-testing] runs-on: ubuntu-latest name: E2E result steps: - run: echo "Success" concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true ================================================ FILE: .github/workflows/lint.yml ================================================ name: Lint on: [pull_request] jobs: typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 - uses: taiga-family/ci/actions/setup/variables@v1.201.0 - uses: taiga-family/ci/actions/setup/node@v1.201.0 - run: npm run typecheck cspell: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 - uses: taiga-family/ci/actions/setup/variables@v1.201.0 - uses: taiga-family/ci/actions/setup/node@v1.201.0 - run: npm run cspell -- --no-progress prettier: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 with: persist-credentials: false - uses: taiga-family/ci/actions/setup/variables@v1.201.0 - uses: taiga-family/ci/actions/setup/node@v1.201.0 - run: npm run prettier ${{ env.SUPPORT_AUTO_PUSH == 'true' && '-- --write' || '-- --check' }} - uses: taiga-family/ci/actions/auto/push@v1.201.0 with: token: ${{ secrets.TAIGA_FAMILY_BOT_PAT }} stylelint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 with: persist-credentials: false - uses: taiga-family/ci/actions/setup/variables@v1.201.0 - uses: taiga-family/ci/actions/setup/node@v1.201.0 - run: npm run stylelint ${{ env.SUPPORT_AUTO_PUSH == 'true' && '-- --fix' || '' }} - uses: taiga-family/ci/actions/auto/push@v1.201.0 with: token: ${{ secrets.TAIGA_FAMILY_BOT_PAT }} eslint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 with: persist-credentials: false - uses: taiga-family/ci/actions/setup/variables@v1.201.0 - uses: taiga-family/ci/actions/setup/node@v1.201.0 - run: npm run lint ${{ env.SUPPORT_AUTO_PUSH == 'true' && '-- --fix' || '' }} - uses: taiga-family/ci/actions/auto/push@v1.201.0 with: token: ${{ secrets.TAIGA_FAMILY_BOT_PAT }} result: needs: [typecheck, cspell, prettier, stylelint, eslint] runs-on: ubuntu-latest name: Lint result steps: - run: echo "Success" concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true ================================================ FILE: .github/workflows/release.yml ================================================ name: ⚠️ Release on: workflow_dispatch: inputs: mode: type: choice description: Bump version as requested required: true options: - patch - minor - alpha - major jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 - uses: taiga-family/ci/actions/setup/variables@v1.201.0 - uses: taiga-family/ci/actions/setup/node@v1.201.0 - uses: taiga-family/ci/actions/run/release-it@v1.201.0 id: release-it with: ref: ${{ github.ref }} mode: ${{ github.event.inputs.mode }} npmToken: ${{ secrets.NPM_TOKEN }} githubToken: ${{ secrets.TAIGA_FAMILY_BOT_PAT }} - uses: taiga-family/ci/actions/run/read-package-json@v1.201.0 id: info - name: Announce to Telegram if: steps.release-it.outputs.released == 'true' uses: taiga-family/ci/actions/messenger/telegram/announce@v1.201.0 with: chatId: ${{ secrets.TAIGA_TELEGRAM_CHAT_ID }} topicId: ${{ secrets.TAIGA_TELEGRAM_CHAT_THREAD_ID }} token: ${{ secrets.TAIGA_TELEGRAM_BOT_TOKEN }} version: v${{ steps.info.outputs.version }} textLink: '@maskito/core' concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true ================================================ FILE: .github/workflows/test.yml ================================================ name: Tests on: pull_request: push: branches: - main jobs: tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 - uses: taiga-family/ci/actions/setup/variables@v1.201.0 - uses: taiga-family/ci/actions/setup/node@v1.201.0 - name: Run tests run: npx nx run-many --target test --all --coverage - name: Archive coverage artifacts uses: actions/upload-artifact@v7.0.1 with: name: coverage-${{ github.workflow }}-${{ github.run_id }} path: coverage codecov: name: Collect coverage needs: [tests] runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 - uses: actions/download-artifact@v8.0.1 with: name: coverage-${{ github.workflow }}-${{ github.run_id }} path: coverage - name: Display structure of coverage files run: tree -L 2 ./coverage -P 'lcov.info' - uses: codecov/codecov-action@v6.0.0 with: directory: ./coverage/core/ flags: summary,core name: core - uses: codecov/codecov-action@v6.0.0 with: directory: ./coverage/kit/ flags: summary,kit name: kit - uses: codecov/codecov-action@v6.0.0 with: directory: ./coverage/phone/ flags: summary,phone name: phone - uses: codecov/codecov-action@v6.0.0 with: directory: ./coverage/angular/ flags: summary,angular name: angular - uses: codecov/codecov-action@v6.0.0 with: directory: ./coverage/react/ flags: summary,react name: react - uses: codecov/codecov-action@v6.0.0 with: directory: ./coverage/vue/ flags: summary,vue name: vue concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true ================================================ FILE: .gitignore ================================================ # compiled output /dist /tmp /out-tsc # Only exists if Bazel was run /bazel-out # dependencies **/node_modules/ # profiling files chrome-profiler-events.json speed-measure-plugin.json # IDEs and editors /.idea .project .classpath .c9/ *.launch *.iml .settings/ *.sublime-workspace # IDE - VSCode .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json .history/* # misc /.angular/cache /.sass-cache /connect.lock /coverage /libpeerconnection.log npm-debug.log yarn-error.log testem.log /typings # System Files .DS_Store Thumbs.db # cypress **/cypress/**/screenshots/** projects/demo-integrations/cypress .nx .ssl RELEASE_BODY.md .cursor/rules/nx-rules.mdc .github/instructions/nx.instructions.md .rollup.cache ================================================ FILE: .husky/commit-msg ================================================ npx --no -- commitlint --edit $1 ================================================ FILE: .husky/pre-commit ================================================ npx lint-staged npm run typecheck ================================================ FILE: .npmrc ================================================ engine-strict=true loglevel=error ================================================ FILE: .release-it.js ================================================ module.exports = require('@taiga-ui/release-it-config'); ================================================ FILE: .ws-context ================================================ { "framework": "angular", "projects/vue/**": { "framework": "vue" }, "projects/react/**": { "framework": "react" } } ================================================ FILE: CHANGELOG.md ================================================ ### [5.2.2](https://github.com/taiga-family/maskito/compare/v5.2.1...v5.2.2) (2026-03-31) ### 🐞 Bug Fixes - **kit**: `Number` with `negativePattern=minusFirst` + `prefix` has unexpected caret shift on minus insertion (#2616) [(1d7bc77)](https://github.com/taiga-family/maskito/commit/1d7bc7761f1e4c7dad3b01b6c651295dd6d31bc4) - **kit**: `Number` should ignore digits in affixes for `min`/`max` validation (#2615) [(227a860)](https://github.com/taiga-family/maskito/commit/227a860453b4d0cee42a96e8ef06d155a44cce06) ### [5.2.1](https://github.com/taiga-family/maskito/compare/v5.2.0...v5.2.1) (2026-03-27) ### 🐞 Bug Fixes - **react**: omitted `d.ts` files (#2607) [(a10e118)](https://github.com/taiga-family/maskito/commit/a10e1187c81cfd4af8935edd7d459c3e6b5c6cc4) - **kit**: `maskitoParseNumber` incorrectly parses postfix containing digits (#2600) [(4805166)](https://github.com/taiga-family/maskito/commit/48051666d3235fe72c5bc90d83405175905ec7fa) ### [5.2.0](https://github.com/taiga-family/maskito/compare/v5.1.2...v5.2.0) (2026-03-25) ### 🚀 Features - **kit**: `Number` supports `thousandSeparatorPattern` (#2584) [(f4e1340)](https://github.com/taiga-family/maskito/commit/f4e1340ddedd8abf04c181c8db31edf420564845) ### 🐞 Bug Fixes - **kit**: validate digit count in time segment for `maskitoParseDateTime` (#2581) [(9c3e9bc)](https://github.com/taiga-family/maskito/commit/9c3e9bccc046be9e9a91ea05255bf55985144df1) ### [5.1.2](https://github.com/taiga-family/maskito/compare/v5.1.1...v5.1.2) (2026-03-13) ### 🐞 Bug Fixes - **kit**: `maskitoParseDate` & `maskitoParseDateTime` supports parsing of dates in formats `mm/dd`, `dd/mm` (#2577) [(a0f073b)](https://github.com/taiga-family/maskito/commit/a0f073b26b3be0d6bf776d970adc65c745cb3d41) - **kit**: `maskitoParseDate` should return `null` for invalid `Date` string (#2561) [(cf2b5f2)](https://github.com/taiga-family/maskito/commit/cf2b5f206a6679d2171bfd25fdc969ffee6dad7f) ### [5.1.1](https://github.com/taiga-family/maskito/compare/v5.1.0...v5.1.1) (2026-02-16) ### 🐞 Bug Fixes - **kit**: `maskitoStringifyDate` incorrectly formats year with leading zeroes (#2538) [(9817f08)](https://github.com/taiga-family/maskito/commit/9817f084dd34628afd1bebe8070189edf8f3fd1f) - **kit**: `Number` deletes the previous non-selected character on the first deletion (#2537) [(40ef2e0)](https://github.com/taiga-family/maskito/commit/40ef2e03b8ecaf564a1d4f055b8d629ba8932370) - **phone**: `Phone` preserves previously entered digits on new digits paste (#2481) [(f836f4f)](https://github.com/taiga-family/maskito/commit/f836f4f5ec94d865a2bcb44960161cfe25fe3a64) ## [5.1.0](https://github.com/taiga-family/maskito/compare/v5.0.1...v5.1.0) (2026-01-22) ### 🚀 Features - **phone:** `Phone` supports national format ([#2461](https://github.com/taiga-family/maskito/issues/2461)) ([c90bca2](https://github.com/taiga-family/maskito/commit/c90bca2c2f0f3bc3746a2559fc84bd688075c1be)) ### 🐞Bug Fixes - **kit:** `Number` fails to dynamically change postfix ([#2501](https://github.com/taiga-family/maskito/issues/2501)) ([cd73d6a](https://github.com/taiga-family/maskito/commit/cd73d6a729068e4b301509b12def6e149a7b5d66)) - **kit:** `Number` throws `Failed to parse String to BigInt` error ([#2509](https://github.com/taiga-family/maskito/issues/2509)) ([7b80f79](https://github.com/taiga-family/maskito/commit/7b80f79140740462d3d924ba49993e1376d2b6e3)) - **react:** `useMaskito` should destroy Maskito instance if element is detached from the DOM ([#2507](https://github.com/taiga-family/maskito/issues/2507)) ([1cdb203](https://github.com/taiga-family/maskito/commit/1cdb20359aaf4fa479420638d76bc7b238a6cb4c)) ### [5.0.1](https://github.com/taiga-family/maskito/compare/v5.0.0...v5.0.1) (2025-12-26) ### 🐞 Bug Fixes - **phone**: `Phone` removes last digit if pasted without '+' (#2480) [(5d709ca)](https://github.com/taiga-family/maskito/commit/5d709ca9c2e4a8abbe99a38c47551a9e6b2d8d36) - **kit**: `maskitoStringifyNumber` supports extremal exponent values (#2463) [(1340257)](https://github.com/taiga-family/maskito/commit/1340257486a86499f94c96dbe67949ada32ebae4) - **phone**: `Phone` with initial value has problems with the first time delete action (#2455) [(fa596fa)](https://github.com/taiga-family/maskito/commit/fa596fa101333b519bc3d760d6f4969151178e1a) ## [5.0.0](https://github.com/taiga-family/maskito/compare/v4.0.1...v5.0.0) (2025-12-03) ### ⚠ BREAKING CHANGES - Bump Safari browser support (#2439) | | < 5.0.0 | ≥ 5.0.0 | | -------------- | ------- | ------- | | Safari Desktop | 13.1+ | 14.1+ | | Safari Mobile | 13.4+ | 14.5+ | - **kit**: `Number` supports `BigInt` (#2431) [(2d2f86d)](https://github.com/taiga-family/maskito/commit/2d2f86dafb1524528305908af27b4df37d9e1330) New default values for `maskitoNumberOptionsGenerator` / `maskitoStringifyNumber`: | MaskitoNumberParams | < 5.0.0 | ≥ 5.0.0 | | ------------------- | ------------------------- | ----------- | | `min` | `Number.MIN_SAFE_INTEGER` | `-Infinity` | | `max` | `Number.MAX_SAFE_INTEGER` | `Infinity` | ### [4.0.1](https://github.com/taiga-family/maskito/compare/v4.0.0...v4.0.1) (2025-11-14) ### 🐞 Bug Fixes - **kit**: `Time` with `[step]` ignores `timeSegmentMinValues` on arrow stepping (#2420) [(398a5c1)](https://github.com/taiga-family/maskito/commit/398a5c163d03502d413ca15c8756f41891b75197) - **kit**: `DateRange` with `[minLength]` & `[maxLength]` incorrectly appends month in backward direction (#2369) [(3c80959)](https://github.com/taiga-family/maskito/commit/3c80959773a4c44b29e70a420bc1fc6e074030b9) ## [4.0.0](https://github.com/taiga-family/maskito/compare/v3.11.1...v4.0.0) (2025-10-13) ### ⚠ BREAKING CHANGES - **kit**: delete deprecated `precision` & `decimalZeroPadding` parameters from `Number` mask (#2354) **Previous behavior:** ```ts import {maskitoNumberOptionsGenerator} from '@maskito/kit'; const options = maskitoNumberOptionsGenerator({ precision: 2, // ---> Use `maximumFractionDigits` instead decimalZeroPadding: true, // ---> Use `minimumFractionDigits` instead }); ```

⬇️

**New behavior**: ```ts import {maskitoNumberOptionsGenerator} from '@maskito/kit'; const options = maskitoNumberOptionsGenerator({ maximumFractionDigits: 2, minimumFractionDigits: 2, }); ``` - **kit**: `maskitoParseNumber` accepts only `MaskitoNumberParams` as the 2nd argument (#2355) **Previous behavior:** ```ts import {maskitoParseNumber} from '@maskito/kit'; maskitoParseNumber( '0,42', ',', // decimalSeparator ); ```

⬇️

**New behavior**: ```ts import {maskitoParseNumber} from '@maskito/kit'; maskitoParseNumber( '0,42', {decimalSeparator: ','}, // MaskitoNumberParams ); ``` - **kit**: remove invalid `MM.SS.MSS` type from `MaskitoTimeMode` (use `MM:SS.MSS` instead) (#2365) - **angular**: bump minimum required Angular version (16+ => 19+) (#2347) (#2348) (#2349) - **angular**: `MaskitoDirective` uses model inputs (#2363) ### [3.11.1](https://github.com/taiga-family/maskito/compare/v3.11.0...v3.11.1) (2025-09-30) ### 🐞 Bug Fixes - **kit**: resolve circular dependencies inside `Number` mask (#2344) [(efb3039)](https://github.com/taiga-family/maskito/commit/efb303980905c33bfe58e0163b74c72da5f83fcd) - **kit**: `Number` fails to clear initial value (by selecting all + Backspace/Delete) (#2343) [(63f6e72)](https://github.com/taiga-family/maskito/commit/63f6e725af215dc492ddca02d30123de0dd026de) - **kit**: `Number` has broken support for postfix with leading point (#2337) [(e9a3598)](https://github.com/taiga-family/maskito/commit/e9a3598c9ce7f5c39f932d4e6b3c0ffabcde3741) ### [3.11.0](https://github.com/taiga-family/maskito/compare/v3.10.3...v3.11.0) (2025-09-23) ### 🚀 Features - **kit**: `Number` supports minus before prefix (#2281) [(480c1fd)](https://github.com/taiga-family/maskito/commit/480c1fde7693b62df768364c0df00fc7328cb4e6) - **kit**: `Number` uses `toNumberParts` / `fromNumberParts` approach (#2270) [(891780a)](https://github.com/taiga-family/maskito/commit/891780a8f179345a49dbe8b8036e639ae0a98cbd) ### 🐞 Bug Fixes - **kit**: `PostfixPostprocessor` duplicates postfix on paste of value with incompleted postfix (#2267) [(2707771)](https://github.com/taiga-family/maskito/commit/27077719ffc8628758664638e802e1ad3c9f8e27) - **kit**: `maskitoStringifyTime` and `maskitoParseTime` should support `AM` / `PM` formats (#2260) [(a0aea6f)](https://github.com/taiga-family/maskito/commit/a0aea6f741fea3139f4e7d7c8f84ce46c1738c26) - **angular**: use `@Input` setters instead of `ngOnChanges` to handle programmatic changes (#2257) [(cb8c129)](https://github.com/taiga-family/maskito/commit/cb8c129f1afd196a38f87dd4b36328ddea3b60a5) ### [3.10.3](https://github.com/taiga-family/maskito/compare/v3.10.2...v3.10.3) (2025-08-06) ### 🐞 Bug Fixes - **kit**: `DateRange` + `minLength` / `maxLength` has incorrect limits (#2210) [(e8917e0)](https://github.com/taiga-family/maskito/commit/e8917e0a124b26ffc9806f74c5c70016084f5280) - **kit**: `maskitoStringifyNumber` fails to stringify number with exponential notation (#2224) [(9fe0b08)](https://github.com/taiga-family/maskito/commit/9fe0b080b2703a72674a25b8ce352486cc274663) ### [3.10.2](https://github.com/taiga-family/maskito/compare/v3.10.1...v3.10.2) (2025-07-28) ### 🐞 Bug Fixes - **kit**: `Number` with `input[maxlength]` is incompatible with `document.execCommand('delete')` (#2217) [(2604d2c)](https://github.com/taiga-family/maskito/commit/2604d2ce8dc60e16f464a3fc4328f907bef58d55) ### [3.10.1](https://github.com/taiga-family/maskito/compare/v3.10.0...v3.10.1) (2025-07-18) ### 🐞 Bug Fixes - **core**: dynamic mask switching to mask without fixed character fails on new character insertion (#2207) [(50e68d4)](https://github.com/taiga-family/maskito/commit/50e68d4bb8f6e5330bda76c67806514a7fa53294) ### [3.10.0](https://github.com/taiga-family/maskito/compare/v3.9.1...v3.10.0) (2025-07-04) ### 🚀 Features - **kit**: `Time` supports `prefix` & `postfix` parameters (#2185) [(2cc7462)](https://github.com/taiga-family/maskito/commit/2cc7462583a2fe372d0cad312fb5f0d90ca0fe8e) ### 🐞 Bug Fixes - **core**: invalid behavior of dynamic mask expression with trailing fixed characters (#2184) [(cecf9d6)](https://github.com/taiga-family/maskito/commit/cecf9d69468e56de8ff4f39af7ebc07d5a686fe8) - **core**: do not insert fixed character on attempt to enter invalid character at its position (#2181) [(7a51702)](https://github.com/taiga-family/maskito/commit/7a51702361237a41cd9bbdcdbb8e46d0bfa2e4bc) - **kit**: date-related mask with month-first mode has incorrect zero-padding logic (#2166) [(26294e8)](https://github.com/taiga-family/maskito/commit/26294e8250591c727f99ccec563e8492df7c1068) ### [3.9.1](https://github.com/taiga-family/maskito/compare/v3.9.0...v3.9.1) (2025-06-23) ### 🐞 Bug Fixes - **kit**: `Number` with custom `minusSign` has broken `min`/`max` behavior (#2164) [(52ed25d)](https://github.com/taiga-family/maskito/commit/52ed25debaa2838a0b360983a508a3d627c78277) - **core**: Android with Microsoft SwiftKey Keyboard ignores `preventDefault()` for `beforeinput` event on backspace (#2163) [(722d9af)](https://github.com/taiga-family/maskito/commit/722d9afc265df8392613c770759df3bb8955e08e) ### [3.9.0](https://github.com/taiga-family/maskito/compare/v3.8.0...v3.9.0) (2025-06-05) ### 🚀 Features - **react**: add support for React-specific `onChange` event handler (#2153) [(e941847)](https://github.com/taiga-family/maskito/commit/e941847990662835343c4e25d3f2b2e64ab54345) ### 🐞 Bug Fixes - **core**: do not unnecessarily trigger element's `value` setter on every keystroke (#2152) [(fd3449b)](https://github.com/taiga-family/maskito/commit/fd3449b69f88dbcab5b06b03ff19273b511bcd64) ### [3.8.0](https://github.com/taiga-family/maskito/compare/v3.7.2...v3.8.0) (2025-05-13) ### 🚀 Features - **angular**: new `MaskitoPattern` directive (#2081) [(c3f7142)](https://github.com/taiga-family/maskito/commit/c3f7142245b603af9136541de9d181189e01a7a3) ### 🐞 Bug Fixes - **kit**: update the first digit zero-padding logic for date-related mask (#2117) [(b5b2598)](https://github.com/taiga-family/maskito/commit/b5b2598f455f3ad3438c3bd89b81009aca82f17c) - **core**: incorrect handle of paste event for `<input />` with `maxlength` attribute (#2090) [(e20e50b)](https://github.com/taiga-family/maskito/commit/e20e50bb92aca9d70bc483f9fc66904264a64c35) - **kit**: `Number` should support non-erasable minus (as `prefix`) for `max <= 0` (#2087) [(3910914)](https://github.com/taiga-family/maskito/commit/39109144075d58734d1545be888cbd03c5b6286e) ### [3.7.2](https://github.com/taiga-family/maskito/compare/v3.7.1...v3.7.2) (2025-04-22) ### 🐞 Bug Fixes - **kit**: missing export of `maskitoParseDateTime` & `maskitoStringifyDateTime` utilities (#2074) [(6aa34aa)](https://github.com/taiga-family/maskito/commit/6aa34aa610cf140248bc7a691beb5aaba1f0e0cd) ### [3.7.1](https://github.com/taiga-family/maskito/compare/v3.7.0...v3.7.1) (2025-04-16) ### 🐞 Bug Fixes - **core**: updated selection range (even if textfield value is untouched) should not be ignored (#2069) [(9276117)](https://github.com/taiga-family/maskito/commit/927611775e8d23eb89663150cd84e7981b12d2e7) ### [3.7.0](https://github.com/taiga-family/maskito/compare/v3.6.0...v3.7.0) (2025-04-15) ### 🚀 Features - **kit**: new `maskitoParseDateTime` and `maskitoStringifyDateTime` helpers (#2055) [(5028084)](https://github.com/taiga-family/maskito/commit/5028084f9be876cf8b1dc2607956cd4906285c43) ### 🐞 Bug Fixes - **core**: add possibility to overwrites `selection` property in processors (#2053) [(de354f4)](https://github.com/taiga-family/maskito/commit/de354f4fbeed7a632e23c0e1d00809effbb0229b) ### [3.6.0](https://github.com/taiga-family/maskito/compare/v3.5.0...v3.6.0) (2025-04-08) ### 🚀 Features - **kit**: `Number` supports new properties `minimumFractionDigits` & `maximumFractionDigits` (#2022) [(8719b9e)](https://github.com/taiga-family/maskito/commit/8719b9e30d6463deff4aed213cba774189ddd305) ### 🐞 Bug Fixes - **core**: double space bar removes characters (#2040) [(ccbebd8)](https://github.com/taiga-family/maskito/commit/ccbebd878ae7ba92da0a8d25d5b9d0b5c3ed3bcf) ### [3.5.0](https://github.com/taiga-family/maskito/compare/v3.4.0...v3.5.0) (2025-03-21) ### 🚀 Features - **kit**: `Time` supports `MM:SS` mode (#2008) [(b93ad1e)](https://github.com/taiga-family/maskito/commit/b93ad1ecc71f608dd68de01b43487153b8e89d95) ### 🐞 Bug Fixes - **kit**: `maskitoParseDate` should return `null` for incompleted date string (#2009) [(9eec35b)](https://github.com/taiga-family/maskito/commit/9eec35b878411a79fec84986cbea94fbdc9f24d8) ### [3.4.0](https://github.com/taiga-family/maskito/compare/v3.3.0...v3.4.0) (2025-03-10) ### 🚀 Features - **kit**: new `maskitoStringifyNumber` helper (#1987) [(cbfd4bc)](https://github.com/taiga-family/maskito/commit/cbfd4bc4bb6ca56bf12667bb3626c55ae1b04c48) ### 🐞 Bug Fixes - **phone**: `Phone` should accept incomplete phone number of selected country (even with `strict=true`) (#1982) [(965d735)](https://github.com/taiga-family/maskito/commit/965d7358ad39888d3844c121dd6934ee66cdc541) ### [3.3.0](https://github.com/taiga-family/maskito/compare/v3.2.1...v3.3.0) (2025-02-28) ### 🚀 Features - **kit**: new `maskitoParseDate` and `maskitoStringifyDate` helpers (#1973) [(208a4ab)](https://github.com/taiga-family/maskito/commit/208a4abc8018b368d3154ebc26a81504b6abec3d) - **kit**: `Date` supports `dd/mm` and `mm/dd` modes (#1939) [(bc290af)](https://github.com/taiga-family/maskito/commit/bc290affdcdc1cd6e088a32a60dc5e74fd00a1d8) ### 🐞 Bug Fixes - **kit**: `SelectionChangeHandler` does not work for Safari after programmatic update of textfield value (#1930) [(34c11d0)](https://github.com/taiga-family/maskito/commit/34c11d0ee88b861ab21d54113aff21f3091a053f) ### [3.2.1](https://github.com/taiga-family/maskito/compare/v3.2.0...v3.2.1) (2024-12-26) ### 🚀 Features - **kit**: remove circular import (#1861) [(15ff0b8)](https://github.com/taiga-family/maskito/commit/15ff0b8558bc954ac6eda07bdb13d087fc2f3491) ### 🐞 Bug Fixes - **kit**: `Number` should ignore `[decimalSeparator]` value if `[precision]=0` (#1908) [(19effe2)](https://github.com/taiga-family/maskito/commit/19effe2c7218646335b2f08c53a1ed3c3f0d89a1) - **kit**: `Number` + postfix (with leading space) adds unnecessary spaces on paste value with trailing spaces (#1865) [(c37b1d6)](https://github.com/taiga-family/maskito/commit/c37b1d636fefee1cba17b4aa07ccdd30edc5ff66) - **kit**: `DateRange` should accept single character date segment paste even if date and range separators are equal (#1796) [(be6a4c3)](https://github.com/taiga-family/maskito/commit/be6a4c3c57132cf320ec462372fd8536dca4781a) ### [3.2.0](https://github.com/taiga-family/maskito/compare/v3.1.2...v3.2.0) (2024-10-29) ### 🚀 Features - **kit**: new `maskitoSelectionChangeHandler` plugin (#1794) [(c6e9a4d)](https://github.com/taiga-family/maskito/commit/c6e9a4d9b1a2e75bc44aaecbda840b84f786d065) ### [3.1.2](https://github.com/taiga-family/maskito/compare/v3.1.1...v3.1.2) (2024-10-22) ### 🐞 Bug Fixes - **kit**: `Time` & `DateTime` has conflicts between `step` & `AM/PM` features (#1791) [(805f70b)](https://github.com/taiga-family/maskito/commit/805f70b74e04fb3b8613f89d84e771c734438dab) - **kit**: `Number` incorrectly shift caret for 1st time insertion into textfield with initial value (#1792) [(0049d91)](https://github.com/taiga-family/maskito/commit/0049d91a0a498977bb5f4cba9fbf9f02cb74dae9) ### [3.1.1](https://github.com/taiga-family/maskito/compare/v3.1.0...v3.1.1) (2024-10-17) ### 🐞 Bug Fixes - **kit**: `Number` fails to prevent user insertion of extra spaces on invalid positions (#1789) [(a40445c)](https://github.com/taiga-family/maskito/commit/a40445cf4d852328a9310a55cf38801e17525476) - **kit**: `DateTime` fails to process value without any separators (paste from clipboard) (#1779) [(1733422)](https://github.com/taiga-family/maskito/commit/1733422b803fda3de9b40a9fa675ef6bb8b5195e) ### [3.1.0](https://github.com/taiga-family/maskito/compare/v3.0.3...v3.1.0) (2024-10-09) ### 🚀 Features - **kit**: `Time` & `DateTime` support `AM` / `PM` formats (#1708) [(98ce35e)](https://github.com/taiga-family/maskito/commit/98ce35e8fd3318a750959d840f36caaf427fe8f0) - **kit**: simplify some code logic for `Time` mask (#1688) [(8c608b8)](https://github.com/taiga-family/maskito/commit/8c608b8cb5eaeca1166b78c6691d38303eb67c6c) ### 🐞 Bug Fixes - **core**: `overwriteMode: replace` has incorrect behavior on attempt to insert invalid characters (#1772) [(5aeb074)](https://github.com/taiga-family/maskito/commit/5aeb0741fa82ad6e43e862059a17b2e78ee9831b) ### [3.0.3](https://github.com/taiga-family/maskito/compare/v3.0.2...v3.0.3) (2024-09-25) ### 🐞 Bug Fixes - **angular**: race condition when `[maskitoOptions]` are changed before long element predicate is resolved (#1696) [(9f9bad3)](https://github.com/taiga-family/maskito/commit/9f9bad3036774fa51350c3c8402cf57f15e789d6) - **kit**: `Time` has invalid segment separator for `MM:SS.MSS` mode (#1687) [(93972be)](https://github.com/taiga-family/maskito/commit/93972be370e1abf4278497b11f61d3c923ae5caa) - **core**: incorrect behavior of `overwriteMode = replace` if selection contains several characters (#1685) [(67c3c10)](https://github.com/taiga-family/maskito/commit/67c3c10704f62efff4c47f1ad802859d54257752) - **react**: race condition when `options` are changed before long element predicate is resolved (#1651) [(f2932ce)](https://github.com/taiga-family/maskito/commit/f2932ce10ec80a1080befaee9e5c235bc41a1b16) ### [3.0.2](https://github.com/taiga-family/maskito/compare/v3.0.1...v3.0.2) (2024-09-20) ### 🐞 Bug Fixes - **core:** `Time` with `[step]` has unexpected cursor jump to the next segment on `ArrowUp`/`ArrowDown` ([#1478](https://github.com/taiga-family/maskito/issues/1478)) ([59a5927](https://github.com/taiga-family/maskito/commit/59a5927822e2c20691dc0948c438d67d497b6381)) - **core:** fix scroll for masked narrow textfields ([#1645](https://github.com/taiga-family/maskito/issues/1645)) ([c6d2828](https://github.com/taiga-family/maskito/commit/c6d282873f10892ecb3536b878d919fc57f5c921)) ### [3.0.1](https://github.com/taiga-family/maskito/compare/v3.0.0...v3.0.1) (2024-08-19) ### 🐞 Bug Fixes - **kit:** `maskitoStringifyTime` was adding `0` on the wrong side ([#1401](https://github.com/taiga-family/maskito/issues/1401)) ([b28ee12](https://github.com/taiga-family/maskito/commit/b28ee12f923b86eb3a8c32d17cd401e9222cfc30)) - **kit:** `Placeholder` should support partial programmatic removal of placeholder's characters ([#1441](https://github.com/taiga-family/maskito/issues/1441)) ([146a557](https://github.com/taiga-family/maskito/commit/146a55723ec4a1ac8b9cfba254056b84173326c9)) - **kit:** `Time` incorrectly validates value if `timeSegmentMaxValues` includes single digit ([#1402](https://github.com/taiga-family/maskito/issues/1402)) ([26670f4](https://github.com/taiga-family/maskito/commit/26670f4dbdfb84495ea0faa127868185d7bb0765)) ## [3.0.0](https://github.com/taiga-family/maskito/compare/v2.5.0...v3.0.0) (2024-07-18) ### ⚠ BREAKING CHANGES - **phone:** remove built-in `RemoveOnBlur` / `AddOnFocus` plugins from `@maskito/phone` ([#1352](https://github.com/taiga-family/maskito/issues/1352)) Learn more: https://maskito.dev/addons/phone#focus-blur - **angular:** bump minimum required Angular version (15+ => 16+) ([#1328](https://github.com/taiga-family/maskito/issues/1328)) - **angular:** delete deprecated `MaskitoModule` & `MaskitoCVA` ([#1391](https://github.com/taiga-family/maskito/issues/1391)) ### 🚀 Features - **core:** new built-in `maskitoChangeEventPlugin` ([#1338](https://github.com/taiga-family/maskito/issues/1338)) Learn more: https://maskito.dev/core-concepts/plugins#change-event ## [2.5.0](https://github.com/taiga-family/maskito/compare/v2.4.0...v2.5.0) (2024-06-24) ### 🚀 Features - **kit:** new `maskitoParseTime` and `maskitoStringifyTime` utils ([#1302](https://github.com/taiga-family/maskito/issues/1302)) ([d0f9b13](https://github.com/taiga-family/maskito/commit/d0f9b1331f3bb18403691ac7c513c31f5123cf78)) ### 🐞 Bug Fixes - **core:** correct handling of browser autofill/suggestion in Firefox ([#1326](https://github.com/taiga-family/maskito/issues/1326)) ([a049207](https://github.com/taiga-family/maskito/commit/a049207b355da72092948a8c556020062fb7c819)) - **kit:** `Date`, `DateRange`, `DateTime` supports multi-character date segments separator ([#1306](https://github.com/taiga-family/maskito/issues/1306)) ([cdf2fae](https://github.com/taiga-family/maskito/commit/cdf2faee4c16cd3963557a511d4ec053e2d41fc0)) - **kit:** move caret after attempt to erase fixed character in a mask with `Placeholder` ([#1307](https://github.com/taiga-family/maskito/issues/1307)) ([87ae431](https://github.com/taiga-family/maskito/commit/87ae431ded798e3c31d6247f965a00c27ddad3f1)) ## [2.4.0](https://github.com/taiga-family/maskito/compare/v2.3.2...v2.4.0) (2024-06-03) ### 🚀 Features - **kit:** `Time` & `DateTime` support increment / decrement of time segment via `ArrowUp` / `ArrowDown` ([#1223](https://github.com/taiga-family/maskito/issues/1223)) ([af961b8](https://github.com/taiga-family/maskito/commit/af961b84f8765e7d2147c80210e3a8ac6ed30597)) - **kit:** `Time` supports `SS.MSS` & `MM.SS.MSS` modes ([#1224](https://github.com/taiga-family/maskito/issues/1224)) ([7bed4bc](https://github.com/taiga-family/maskito/commit/7bed4bcaac14908e7e445b277f5b4b6e5b0fd281)) ### 🐞 Bug Fixes - **core:** add `.select()`-method support for `MaskitoElement` ([#1268](https://github.com/taiga-family/maskito/issues/1268)) ([51f5934](https://github.com/taiga-family/maskito/commit/51f5934f382b7862a6653412b687c46fd318b0bb)) - **kit:** `Number` should support float `min`/`max`-parameters in range -1 < x < 1 ([#1280](https://github.com/taiga-family/maskito/issues/1280)) ([b44013e](https://github.com/taiga-family/maskito/commit/b44013e0a45ffcfa69564f13d634a79d45b4d926)) ### [2.3.2](https://github.com/taiga-family/maskito/compare/v2.3.1...v2.3.2) (2024-05-16) ### 🐞 Bug Fixes - **kit:** `Number` pads integer part with zero if user selects all and then types decimal separator ([#1220](https://github.com/taiga-family/maskito/issues/1220)) ([8371e45](https://github.com/taiga-family/maskito/commit/8371e45767150ebc4db03a2b74c68afd6fe1e593)) - **phone:** revert mistakenly fixated `libphonenumber-js` peer-dependency to just `>=1.0.0` ([#1234](https://github.com/taiga-family/maskito/issues/1234)) ([27ee4a1](https://github.com/taiga-family/maskito/commit/27ee4a1264c0a70a5a06427368b8d18ed0e25bd4)) - **react:** revert mistakenly fixated `react` & `react-demo` peer-dependencies to just `>=16.8` ([#1231](https://github.com/taiga-family/maskito/issues/1231)) ([ae89d6f](https://github.com/taiga-family/maskito/commit/ae89d6ff549dfb21d7db56b26e3c1f3a7044a817)) - **vue:** revert mistakenly fixated `vue` peer-dependency to just `>=3.0.0` ([#1232](https://github.com/taiga-family/maskito/issues/1232)) ([22d84e2](https://github.com/taiga-family/maskito/commit/22d84e2f731ae8798f457466be7c9538d2f40fd9)) ### [2.3.1](https://github.com/taiga-family/maskito/compare/v2.3.0...v2.3.1) (2024-04-23) ### 🐞 Bug Fixes - **kit:** `Number` should drop decimal separator if all digits are erased ([#1211](https://github.com/taiga-family/maskito/issues/1211)) ([5836c96](https://github.com/taiga-family/maskito/commit/5836c965d6ce5ad497aaa59118204adc3e8625d8)) - **kit:** `Number` with `decimalZeroPadding=true` should erase everything on `.00` ([#1207](https://github.com/taiga-family/maskito/issues/1207)) ([d72f225](https://github.com/taiga-family/maskito/commit/d72f2257cec1a023aa81bb7de62e9543404630bd)) - **kit:** `Placeholder` can have now the same character as textfield's value ([#1209](https://github.com/taiga-family/maskito/issues/1209)) ([ed06936](https://github.com/taiga-family/maskito/commit/ed06936c41297cbd2e8ed308558914e9ad6c2eda)) ## [2.3.0](https://github.com/taiga-family/maskito/compare/v2.2.0...v2.3.0) (2024-04-16) ### 🚀 Features - **core:** add `contenteditable` support ([#1039](https://github.com/taiga-family/maskito/issues/1039)) ([0d5bb31](https://github.com/taiga-family/maskito/commit/0d5bb319225fb61f3ac7643c21208122b4a2a2ae)) - **kit:** `DateTime` supports configurable parameter `dateTimeSeparator` ([#1143](https://github.com/taiga-family/maskito/issues/1143)) ([ec86284](https://github.com/taiga-family/maskito/commit/ec8628467814cff7dfae22668370236f402d8146)) ### 🐞 Bug Fixes - **kit:** `Date` formatting errors for `mm/yyyy`, `yyyy/mm`, `mm/yy` modes ([#1177](https://github.com/taiga-family/maskito/issues/1177)) ([948a350](https://github.com/taiga-family/maskito/commit/948a35098da2233bc78793eb7e83b7c5136becbd)) ## [2.2.0](https://github.com/taiga-family/maskito/compare/v2.1.0...v2.2.0) (2024-03-07) ### 🚀 Features - **kit:** `Number` supports new configurable parameter `minusSign` ([#1118](https://github.com/taiga-family/maskito/issues/1118)) ([a7bec35](https://github.com/taiga-family/maskito/commit/a7bec35f19d7dfa4023ad83fa36a935b2d636fc7)) ### 🐞 Bug Fixes - totally disable `Maskito` if nullable options are passed inside `@maskito/{angular,react,vue}` ([#1117](https://github.com/taiga-family/maskito/issues/1117)) ([8cbadcf](https://github.com/taiga-family/maskito/commit/8cbadcfdf9af283dc687b131361f7bb19a7f9b02)) ## [2.1.0](https://github.com/taiga-family/maskito/compare/v2.0.2...v2.1.0) (2024-03-04) ### 🚀 Features - **kit:** `Date` & `DateRange` & `DateTime` has improved zero-padding support for browser autofill & IME composition ([#1027](https://github.com/taiga-family/maskito/issues/1027)) ([77ac01c](https://github.com/taiga-family/maskito/commit/77ac01ca0b5e61d36dc3240a35c3dc93ce5fe93c)) - **kit:** add full-width numbers support for `Time`, `Date`, `DateTime`, `DateRange` ([#1043](https://github.com/taiga-family/maskito/issues/1043)) ([434c9c5](https://github.com/taiga-family/maskito/commit/434c9c5f349ab3c19e11722e95313c5763203b08)) ### 🐞 Bug Fixes - **kit:** `maskitoParseNumber` should interpret japanese prolonged sound mark as pseudo minus ([#1115](https://github.com/taiga-family/maskito/issues/1115)) ([b152698](https://github.com/taiga-family/maskito/commit/b152698fda8ac671286eb5f4a29de62562934fa2)) ### [2.0.2](https://github.com/taiga-family/maskito/compare/v2.0.1...v2.0.2) (2024-02-01) ### 🐞 Bug Fixes - **kit:** `Number` with initial value has problems with the first time input ([#986](https://github.com/taiga-family/maskito/issues/986)) ([e40d3ff](https://github.com/taiga-family/maskito/commit/e40d3ff93c668c8afa60cd347faa7ebec76d0e6a)) - **react:** `@maskito/react` includes again missing `cjs` module format ([#991](https://github.com/taiga-family/maskito/issues/991)) ([18e3e0c](https://github.com/taiga-family/maskito/commit/18e3e0cf8911fa764a73e2e937081186f1dcde79)) ### [2.0.1](https://github.com/taiga-family/maskito/compare/v2.0.0...v2.0.1) (2024-01-31) ### 🐞 Bug Fixes - **core:** `maskitoUpdateElement` should not dispatch `InputEvent` if value is not changed ([#977](https://github.com/taiga-family/maskito/issues/977)) ([2410b64](https://github.com/taiga-family/maskito/commit/2410b6478c88f4d530b4469d7d50b1e4663d1572)) - **core:** don't execute `setSelectionRange` if element is not focused ([#937](https://github.com/taiga-family/maskito/issues/937)) ([92f288b](https://github.com/taiga-family/maskito/commit/92f288b677dbe77f7978308dd7b1612d6bfd68fb)) - **kit:** `Number` rejects the first time input of full width digits ([#955](https://github.com/taiga-family/maskito/issues/955)) ([c416884](https://github.com/taiga-family/maskito/commit/c41688488630e83d69eba795580916145e5fe17c)) - **react:** `@maskito/react` library should not include `core-js` imports ([#962](https://github.com/taiga-family/maskito/issues/962)) ([3b7e401](https://github.com/taiga-family/maskito/commit/3b7e4014029fae206020723c18762f08e92b8c41)) ## [2.0.0](https://github.com/taiga-family/maskito/compare/v1.9.0...v2.0.0) (2024-01-22) ### ⚠ BREAKING CHANGES - **core:** merge `MaskitoElementPredicate` & `MaskitoElementPredicateAsync` into single type ([#757](https://github.com/taiga-family/maskito/issues/757)) - **core:** remove value's calibration on initialization + new `maskitoInitialCalibrationPlugin` ([#778](https://github.com/taiga-family/maskito/issues/778)) - **core:** bump Firefox browser support (55+ => 87+) ([#876](https://github.com/taiga-family/maskito/issues/876)) and drop legacy fallbacks for `Firefox` ([#756](https://github.com/taiga-family/maskito/issues/756)) - **kit:** delete deprecated `separator` for `DateRange` (use `dateSeparator` instead) ([#790](https://github.com/taiga-family/maskito/issues/790)) - **angular:** bump minimum required Angular version (12+ => 15+) ([#710](https://github.com/taiga-family/maskito/issues/710)) ([#720](https://github.com/taiga-family/maskito/issues/720)) ([#725](https://github.com/taiga-family/maskito/issues/725)) - **angular:** deprecate `MaskitoModule` (use standalone `MaskitoDirective`, `MaskitoCVA`, `MaskitoPipe`) ([#754](https://github.com/taiga-family/maskito/issues/754)) ### 🚀 More features - **core:** new built-in `maskitoStrictCompositionPlugin` ([#881](https://github.com/taiga-family/maskito/issues/881)) - **kit:** `Number` allows to enter full width numbers ([#864](https://github.com/taiga-family/maskito/issues/864)) ### 🐞 Bug Fixes - **core:** drop some excess dispatches of `Input`-event ([#882](https://github.com/taiga-family/maskito/issues/882)) - **kit:** add `{bubbles:true}` for `input` events inside all built-in plugins to support `ReactSyntheticEvent` ([#806](https://github.com/taiga-family/maskito/issues/806)) - **kit:** `Number` has problems when prefix/postfix includes `decimalSeparator` symbol ([#874](https://github.com/taiga-family/maskito/issues/874)) ([#816](https://github.com/taiga-family/maskito/issues/816)) ([#921](https://github.com/taiga-family/maskito/issues/921)) - **kit:** `Placeholder` is not compatible with `maskitoEventHandler` + `focus`/`blur` events ([#928](https://github.com/taiga-family/maskito/pull/928)) ## [1.9.0](https://github.com/taiga-family/maskito/compare/v1.8.2...v1.9.0) (2023-11-23) ### 🚀 Features - **phone:** add ability to configure the separator ([#685](https://github.com/taiga-family/maskito/issues/685)) ([ab6bb11](https://github.com/taiga-family/maskito/commit/ab6bb11b1b40e069d31598b676c04456329aaf64)) ### [1.8.2](https://github.com/taiga-family/maskito/compare/v1.8.1...v1.8.2) (2023-11-16) ### 🐞 Bug Fixes - **kit:** `PrefixPostprocessor` has problems with multi-character prefix ([#669](https://github.com/taiga-family/maskito/issues/669)) ([be459e5](https://github.com/taiga-family/maskito/commit/be459e51f3cbf028fa36b1b6a57e47d7fe8482a3)) ### [1.8.1](https://github.com/taiga-family/maskito/compare/v1.8.0...v1.8.1) (2023-10-19) ### 🐞 Bug Fixes - **kit:** `Date` accept single character date segment during paste ([#610](https://github.com/taiga-family/maskito/issues/610)) ([e493198](https://github.com/taiga-family/maskito/commit/e4931987c2fad37894ea07f658f08e35152040df)) ## [1.8.0](https://github.com/taiga-family/maskito/compare/v1.7.0...v1.8.0) (2023-10-18) ### 🚀 Features - **angular:** allow nullable options ([#605](https://github.com/taiga-family/maskito/issues/605)) ([21eaa7c](https://github.com/taiga-family/maskito/commit/21eaa7c0c0e7d5173c6f070f5222ba6492e196a6)) ### 🐞 Bug Fixes - **kit:** `Number` has broken zero padding when `decimalSeparator` equals to non-default value ([#586](https://github.com/taiga-family/maskito/issues/586)) ([7241761](https://github.com/taiga-family/maskito/commit/72417614dd4974c22854dfacc2ee35044c080074)) ## [1.7.0](https://github.com/taiga-family/maskito/compare/v1.6.0...v1.7.0) (2023-09-15) ### 🚀 Features New `@maskito/phone` library ([#425](https://github.com/taiga-family/maskito/pull/425)) ([#482](https://github.com/taiga-family/maskito/issues/482)) Learn more: https://maskito.dev/addons/phone ## [1.6.0](https://github.com/taiga-family/maskito/compare/v1.5.1...v1.6.0) (2023-09-15) ### 🚀 Features - **react:** `elementPredicate` can accept asynchronous predicate ([#502](https://github.com/taiga-family/maskito/issues/502)) ([4bbf758](https://github.com/taiga-family/maskito/commit/4bbf758107ed4b2fdbde5a241f22c0f363c22104)) ### 🐞 Bug Fixes - **kit:** `Number` should accept all types of spaces as interchangeable characters for `thousandSeparator` ([#505](https://github.com/taiga-family/maskito/issues/505)) ([73975bb](https://github.com/taiga-family/maskito/commit/73975bbc676487330359056c367f73e32ea6eaf4)) ### [1.5.1](https://github.com/taiga-family/maskito/compare/v1.5.0...v1.5.1) (2023-09-08) ### 🐞 Bug Fixes - **vue:** `elementPredicate` should accept `MaskitoElementPredicateAsync` type ([#487](https://github.com/taiga-family/maskito/issues/487)) ([fe7e9dc](https://github.com/taiga-family/maskito/commit/fe7e9dcb468bf3ab30978c947d8fa21cc0e51a75)) ## [1.5.0](https://github.com/taiga-family/maskito/compare/v1.4.0...v1.5.0) (2023-09-04) ### 🚀 Features - **core:** add IME composition support ([#467](https://github.com/taiga-family/maskito/issues/467)) ([e7d664b](https://github.com/taiga-family/maskito/commit/e7d664b66a008a742c0a532e341b0e0bb0a0f759)) - **demo:** documentation is now available at https://maskito.dev ([#392](https://github.com/taiga-family/maskito/issues/392)) ([355f87f](https://github.com/taiga-family/maskito/commit/355f87fd536758bc2db59f760ed114d28264122a)) ### 🐞 Bug Fixes - **kit:** `maskitoCaretGuard` doesn't work after focus on ` `, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestHostComponent { public readonly options = signal(null); public readonly predicate = signal(MASKITO_DEFAULT_ELEMENT_PREDICATE); } let fixture: ComponentFixture; let destroySpy: ReturnType; function getDirective(): MaskitoDirective { return fixture.debugElement.children[0]!.injector.get(MaskitoDirective); } function getMaskedElement(): Maskito | null { return (getDirective() as unknown as {maskedElement: Maskito | null}) .maskedElement; } beforeEach(() => { TestBed.configureTestingModule({imports: [TestHostComponent]}); destroySpy = jest.spyOn(Maskito.prototype, 'destroy'); fixture = TestBed.createComponent(TestHostComponent); fixture.detectChanges(); }); afterEach(() => { jest.restoreAllMocks(); }); describe('when options is null', () => { it('does not create a Maskito instance', async () => { await fixture.whenStable(); expect(getMaskedElement()).toBeNull(); }); }); describe('when options are provided', () => { beforeEach(async () => { fixture.componentInstance.options.set(DIGIT_ONLY); fixture.detectChanges(); await fixture.whenStable(); }); it('creates a Maskito instance', () => { expect(getMaskedElement()).toBeInstanceOf(Maskito); }); it('destroys the old instance and creates a new one when options change', async () => { fixture.componentInstance.options.set({mask: /^[a-z]*$/}); fixture.detectChanges(); await fixture.whenStable(); expect(destroySpy).toHaveBeenCalledTimes(1); expect(getMaskedElement()).toBeInstanceOf(Maskito); }); it('destroys the instance when options become null', async () => { fixture.componentInstance.options.set(null); fixture.detectChanges(); await fixture.whenStable(); expect(destroySpy).toHaveBeenCalledTimes(1); expect(getMaskedElement()).toBeNull(); }); it('destroys the instance when the directive is destroyed', () => { const directive = getDirective() as unknown as { maskedElement: Maskito | null; }; fixture.destroy(); expect(destroySpy).toHaveBeenCalledTimes(1); expect(directive.maskedElement).toBeNull(); }); }); describe('stale async predicate', () => { function makeControlledPredicate(): { predicate: MaskitoElementPredicate; resolveCall: (index: number) => void; } { const resolvers: Array<() => void> = []; const predicate: MaskitoElementPredicate = async (el) => new Promise((resolve) => { resolvers.push(() => resolve(el as HTMLInputElement)); }); return {predicate, resolveCall: (i) => resolvers[i]!()}; } it('ignores the result when elementPredicate changes before it resolves', async () => { const {predicate: slowPredicate, resolveCall} = makeControlledPredicate(); fixture.componentInstance.options.set(DIGIT_ONLY); fixture.componentInstance.predicate.set(slowPredicate); fixture.detectChanges(); await fixture.whenStable(); expect(getMaskedElement()).toBeNull(); fixture.componentInstance.predicate.set(MASKITO_DEFAULT_ELEMENT_PREDICATE); fixture.detectChanges(); await fixture.whenStable(); expect(getMaskedElement()).toBeInstanceOf(Maskito); expect(destroySpy).not.toHaveBeenCalled(); resolveCall(0); await fixture.whenStable(); expect(destroySpy).not.toHaveBeenCalled(); expect(getMaskedElement()).toBeInstanceOf(Maskito); }); it('ignores the result when options change before the predicate resolves', async () => { const {predicate: slowPredicate, resolveCall} = makeControlledPredicate(); fixture.componentInstance.options.set(DIGIT_ONLY); fixture.componentInstance.predicate.set(slowPredicate); fixture.detectChanges(); await fixture.whenStable(); expect(getMaskedElement()).toBeNull(); fixture.componentInstance.options.set({mask: /^[a-z]*$/}); fixture.detectChanges(); await fixture.whenStable(); resolveCall(0); await fixture.whenStable(); expect(destroySpy).not.toHaveBeenCalled(); expect(getMaskedElement()).toBeNull(); }); }); }); ================================================ FILE: projects/angular/src/lib/tests/maskito.spec.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {type ComponentFixture, TestBed} from '@angular/core/testing'; import {FormControl, ReactiveFormsModule} from '@angular/forms'; import {beforeEach, describe, expect, it} from '@jest/globals'; import {MaskitoDirective, MaskitoPipe} from '@maskito/angular'; import type {MaskitoOptions} from '@maskito/core'; describe('Maskito Angular package', () => { @Component({ imports: [MaskitoDirective, MaskitoPipe, ReactiveFormsModule], template: `
{{ control.value | maskito: options }}
`, // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection changeDetection: ChangeDetectionStrategy.Default, }) class TestComponent { public readonly control = new FormControl(); public options: MaskitoOptions | null = { mask: /^\d+(,\d{0,2})?$/, preprocessors: [ ({elementState, data}) => { const {value, selection} = elementState; return { elementState: { selection, value: value.replace('.', ','), }, data: data.replace('.', ','), }; }, ], }; } let fixture: ComponentFixture; beforeEach(() => { TestBed.configureTestingModule({imports: [TestComponent]}); fixture = TestBed.createComponent(TestComponent); fixture.detectChanges(); }); it('null is treated as empty string', () => { expect(getText()).toBe(''); expect(getValue()).toBe(''); }); it('formats new control value', () => { fixture.componentInstance.control.setValue(12345.6789); fixture.detectChanges(); expect(getText()).toBe('12345,67'); expect(getValue()).toBe('12345,67'); }); it('disable mask formatting if options is null', () => { fixture.componentInstance.options = null; fixture.detectChanges(); fixture.componentInstance.control.setValue(123456.9999); fixture.detectChanges(); expect(getText()).toBe('123456.9999'); expect(getValue()).toBe('123456.9999'); }); function getText(): string { return fixture.debugElement.nativeElement .querySelector('#pipe') .textContent.trim(); } function getValue(): string { return fixture.debugElement.nativeElement.querySelector('#input').value; } }); ================================================ FILE: projects/angular/src/test-setup.ts ================================================ import {setupZoneTestEnv} from 'jest-preset-angular/setup-env/zone'; setupZoneTestEnv(); ================================================ FILE: projects/angular/tsconfig.lib.prod.json ================================================ { "extends": "../../tsconfig.build.json", "compilerOptions": { "declarationMap": false } } ================================================ FILE: projects/core/README.md ================================================ # @maskito/core [![npm version](https://img.shields.io/npm/v/@maskito/core.svg)](https://npmjs.com/package/@maskito/core) [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@maskito/core)](https://bundlephobia.com/result?p=@maskito/core)

Maskito logo

DocumentationSubmit an IssueContact Us

> It is the main zero-dependency and framework-agnostic Maskito's package.
It can be used alone in > vanilla JavaScript project. ## How to install ```bash npm i @maskito/core ``` ================================================ FILE: projects/core/jest.config.ts ================================================ export default { displayName: 'core', preset: '../../jest.preset.js', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], coverageDirectory: '../../coverage/core', }; ================================================ FILE: projects/core/package.json ================================================ { "name": "@maskito/core", "version": "5.2.2", "description": "The main zero-dependency and framework-agnostic Maskito's package to create an input mask", "keywords": [ "input", "mask", "inputmask", "input-mask", "text-mask", "format", "input-format", "input-formatting", "javascript", "typescript" ], "homepage": "https://maskito.dev", "bugs": "https://github.com/taiga-family/maskito/issues", "repository": { "type": "git", "url": "https://github.com/taiga-family/maskito.git" }, "license": "Apache-2.0", "author": { "email": "nikita.s.barsukov@gmail.com", "name": "Nikita Barsukov", "url": "https://github.com/nsbarsukov" }, "contributors": [ { "email": "alexander@inkin.ru", "name": "Alex Inkin" }, { "email": "vladimir.potekh@gmail.com", "name": "Vladimir Potekhin" }, { "email": "nikita.s.barsukov@gmail.com", "name": "Nikita Barsukov" }, { "email": "nextzeddicus@gmail.com", "name": "Georgiy Lunin" } ] } ================================================ FILE: projects/core/project.json ================================================ { "$schema": "../../node_modules/nx/schemas/project-schema.json", "name": "core", "projectType": "library", "sourceRoot": "projects/core/src", "tags": [], "targets": { "build": { "executor": "@nx/rollup:rollup", "options": { "assets": [ { "glob": "README.md", "input": "{projectRoot}", "output": "." } ], "compiler": "tsc", "external": "all", "format": ["esm", "cjs"], "main": "{projectRoot}/src/index.ts", "outputPath": "dist/{projectName}", "project": "{projectRoot}/package.json", "tsConfig": "tsconfig.build.json", "useLegacyTypescriptPlugin": false }, "outputs": ["{options.outputPath}"] }, "publish": { "dependsOn": [ { "params": "ignore", "target": "build" } ], "executor": "nx:run-commands", "options": { "command": "npm publish ./dist/{projectName} --ignore-scripts" } }, "test": { "executor": "@nx/jest:jest", "options": { "jestConfig": "{projectRoot}/jest.config.ts" }, "outputs": ["{workspaceRoot}/coverage/{projectName}"] } } } ================================================ FILE: projects/core/src/index.ts ================================================ export { MASKITO_DEFAULT_ELEMENT_PREDICATE, MASKITO_DEFAULT_OPTIONS, } from './lib/constants'; export {Maskito} from './lib/mask'; export { maskitoChangeEventPlugin, maskitoInitialCalibrationPlugin, maskitoStrictCompositionPlugin, } from './lib/plugins'; export type { MaskitoElement, MaskitoElementPredicate, MaskitoMask, MaskitoMaskExpression, MaskitoOptions, MaskitoPlugin, MaskitoPostprocessor, MaskitoPreprocessor, } from './lib/types'; export { maskitoAdaptContentEditable, maskitoPipe, maskitoTransform, maskitoUpdateElement, } from './lib/utils'; ================================================ FILE: projects/core/src/lib/classes/index.ts ================================================ export {MaskHistory} from './mask-history'; export {MaskModel} from './mask-model/mask-model'; ================================================ FILE: projects/core/src/lib/classes/mask-history.ts ================================================ import type {ElementState, TypedInputEvent} from '../types'; export abstract class MaskHistory { private now: ElementState | null = null; private readonly past: ElementState[] = []; private future: ElementState[] = []; protected abstract updateElementState( state: ElementState, eventInit: Pick, ): void; protected undo(): void { const state = this.past.pop(); if (state && this.now) { this.future.push(this.now); this.updateElement(state, 'historyUndo'); } } protected redo(): void { const state = this.future.pop(); if (state && this.now) { this.past.push(this.now); this.updateElement(state, 'historyRedo'); } } protected updateHistory(state: ElementState): void { if (!this.now) { this.now = state; return; } const isValueChanged = this.now.value !== state.value; const isSelectionChanged = this.now.selection.some( (item, index) => item !== state.selection[index], ); if (!isValueChanged && !isSelectionChanged) { return; } if (isValueChanged) { this.past.push(this.now); this.future = []; } this.now = state; } private updateElement( state: ElementState, inputType: TypedInputEvent['inputType'], ): void { this.now = state; this.updateElementState(state, {inputType, data: null}); } } ================================================ FILE: projects/core/src/lib/classes/mask-model/mask-model.ts ================================================ import type { ElementState, MaskitoMaskExpression, MaskitoOptions, SelectionRange, } from '../../types'; import {areElementStatesEqual} from '../../utils/element-states-equality'; import {applyOverwriteMode} from './utils/apply-overwrite-mode'; import {calibrateValueByMask} from './utils/calibrate-value-by-mask'; import {removeFixedMaskCharacters} from './utils/remove-fixed-mask-characters'; export class MaskModel implements ElementState { private readonly unmaskInitialState: ElementState = {value: '', selection: [0, 0]}; public value = ''; public selection: SelectionRange = [0, 0]; constructor( initialElementState: ElementState, private readonly maskOptions: Required, ) { const expression = this.getMaskExpression(initialElementState); const {value, selection} = calibrateValueByMask(initialElementState, expression); this.unmaskInitialState = removeFixedMaskCharacters( {value, selection}, expression, ); this.value = value; this.selection = selection; } public addCharacters(newCharacters: string): void { const {value, selection, maskOptions} = this; const initialElementState = {value, selection} as const; const { selection: [from, to], } = applyOverwriteMode( initialElementState, newCharacters, maskOptions.overwriteMode, ); const maskExpression = this.getMaskExpression({ value: `${value.slice(0, from)}${newCharacters}${value.slice(to)}`, selection: [from + newCharacters.length, from + newCharacters.length], }); const [unmaskedFrom, unmaskedTo] = applyOverwriteMode( this.unmaskInitialState, newCharacters, maskOptions.overwriteMode, ).selection; const newUnmaskedLeadingValuePart = `${this.unmaskInitialState.value.slice(0, unmaskedFrom)}${newCharacters}`; const newCaretIndex = newUnmaskedLeadingValuePart.length; const maskedElementState = calibrateValueByMask( { value: `${newUnmaskedLeadingValuePart}${this.unmaskInitialState.value.slice(unmaskedTo)}`, selection: [newCaretIndex, newCaretIndex], }, maskExpression, initialElementState, ); const prevLeadingPart = value.slice(0, from); const newLeadingPartState = calibrateValueByMask( { value: newUnmaskedLeadingValuePart, selection: [newCaretIndex, newCaretIndex], }, maskExpression, initialElementState, ); const isInvalidCharsInsertion = newLeadingPartState.value === prevLeadingPart || (newLeadingPartState.value.length < prevLeadingPart.length && removeFixedMaskCharacters(newLeadingPartState, maskExpression).value === this.unmaskInitialState.value.slice(0, unmaskedFrom)); if ( isInvalidCharsInsertion || areElementStatesEqual(this, maskedElementState) // If typing new characters does not change value ) { throw new Error('Invalid mask value'); } this.value = maskedElementState.value; this.selection = maskedElementState.selection; } public deleteCharacters(): void { const [from, to] = this.selection; if (from === to || !to) { return; } const {value} = this; const maskExpression = this.getMaskExpression({ value: `${value.slice(0, from)}${value.slice(to)}`, selection: [from, from], }); const initialElementState = {value, selection: [from, to]} as const; const [unmaskedFrom, unmaskedTo] = this.unmaskInitialState.selection; const newUnmaskedValue = `${this.unmaskInitialState.value.slice(0, unmaskedFrom)}${this.unmaskInitialState.value.slice(unmaskedTo)}`; const maskedElementState = calibrateValueByMask( {value: newUnmaskedValue, selection: [unmaskedFrom, unmaskedFrom]}, maskExpression, initialElementState, ); this.value = maskedElementState.value; this.selection = maskedElementState.selection; } private getMaskExpression(elementState: ElementState): MaskitoMaskExpression { const {mask} = this.maskOptions; return typeof mask === 'function' ? mask(elementState) : mask; } } ================================================ FILE: projects/core/src/lib/classes/mask-model/tests/dynamic-mask.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {MASKITO_DEFAULT_OPTIONS} from '@maskito/core'; import type {ElementState, MaskitoMask, MaskitoOptions} from '../../../types'; import {MaskModel} from '../mask-model'; const EMPTY_STATE: ElementState = { selection: [0, 0], value: '', }; describe('MaskModel | Dynamic mask', () => { describe('switching on the fly works', () => { const SHORT: MaskitoMask = Array.from({length: 10}).fill(/\d/); const MEDIUM: MaskitoMask = [ /\d/, /\d/, /\d/, /\d/, ' ', /\d/, /\d/, /\d/, /\d/, ' ', /\d/, /\d/, /\d/, /\d/, ' ', /\d/, /\d/, /\d/, /\d/, ]; const LONG: MaskitoMask = Array.from({length: 20}).fill(/\d/); const maskitoOptions: Required = { ...MASKITO_DEFAULT_OPTIONS, mask: ({value}) => { const digitsCount = value.replaceAll(/\D/g, '').length; if (digitsCount <= 10) { return SHORT; } if (digitsCount <= 16) { return MEDIUM; } return LONG; }, }; it('enable short mask if number of digits is <=10', () => { const maskModel = new MaskModel(EMPTY_STATE, maskitoOptions); maskModel.addCharacters('01234abc56789'); expect(maskModel.value).toBe('0123456789'); expect(maskModel.selection).toEqual([ '0123456789'.length, '0123456789'.length, ]); }); it('enable medium mask if number of digits is 10 < x <= 16', () => { const maskModel = new MaskModel(EMPTY_STATE, maskitoOptions); maskModel.addCharacters('01234abc56789123456'); expect(maskModel.value).toBe('0123 4567 8912 3456'); expect(maskModel.selection).toEqual([ '0123 4567 8912 3456'.length, '0123 4567 8912 3456'.length, ]); }); it('enable long mask if number of digits is >16 (by paste)', () => { const maskModel = new MaskModel(EMPTY_STATE, maskitoOptions); maskModel.addCharacters('01234abc567891234567'); expect(maskModel.value).toBe('01234567891234567'); expect(maskModel.selection).toEqual([ '01234567891234567'.length, '01234567891234567'.length, ]); }); it('enable long mask if number of digits is >16 (by adding new character to the previous mask)', () => { const initialValue = '0123 4567 8912 3456'; const maskModel = new MaskModel( { value: initialValue, selection: [initialValue.length, initialValue.length], }, maskitoOptions, ); maskModel.addCharacters('7'); expect(maskModel.value).toBe('01234567891234567'); expect(maskModel.selection).toEqual([ '01234567891234567'.length, '01234567891234567'.length, ]); }); }); }); ================================================ FILE: projects/core/src/lib/classes/mask-model/tests/mask-model-fixed-characters.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {MASKITO_DEFAULT_OPTIONS} from '../../../constants'; import type {MaskitoMask, MaskitoOptions} from '../../../types'; import {MaskModel} from '../mask-model'; describe('MaskModel | Fixed characters', () => { describe('New typed character is equal to the previous (already existing) fixed character', () => { const phoneMaskitoOptions: Required = { ...MASKITO_DEFAULT_OPTIONS, mask: [ '+', '7', ' ', '(', /\d/, /\d/, /\d/, ')', ' ', /\d/, '0', /\d/, '-', /\d/, /\d/, '-', /\d/, /\d/, ], }; const check = ({ initialValue, addedCharacters, expectedNewValue, }: { initialValue: string; addedCharacters: string; expectedNewValue: string; }): void => { const selection: [number, number] = [ initialValue.length, initialValue.length, ]; const maskModel = new MaskModel( { selection, value: initialValue, }, phoneMaskitoOptions, ); try { maskModel.addCharacters(addedCharacters); } finally { expect(maskModel.value).toBe(expectedNewValue); expect(maskModel.selection).toEqual([ expectedNewValue.length, expectedNewValue.length, ]); } }; it('+7| => Type 7 => +7 (7', () => { check({ initialValue: '+7', addedCharacters: '7', expectedNewValue: '+7 (7', }); }); it('+7| => Type 9 => +7 (9', () => { check({ initialValue: '+7', addedCharacters: '9', expectedNewValue: '+7 (9', }); }); it('+7 | (space after seven) => Type 7 => +7 (7', () => { check({ initialValue: '+7 ', addedCharacters: '7', expectedNewValue: '+7 (7', }); }); it('+7 (7| => Type 7 => +7 (77', () => { check({ initialValue: '+7 (7', addedCharacters: '7', expectedNewValue: '+7 (77', }); }); it('+7 (900) 2| (next character is fixed character "0") => Type 1 => +7 (900) 201|', () => { check({ initialValue: '+7 (900) 2', addedCharacters: '1', expectedNewValue: '+7 (900) 201', }); }); it('+7 (900) 20| => Type 0 => +7 (900) 200|', () => { check({ initialValue: '+7 (900) 20', addedCharacters: '0', expectedNewValue: '+7 (900) 200', }); }); }); describe('Attempt to insert invalid characters for `overwriteMode: replace`', () => { const testCases: Record = { '["$", /d/, /d/]': ['$', /\d/, /\d/], 'dynamic mask': ({value}) => { const digitsCount = value.replaceAll(/\D/g, '').length; return [ '$', ...Array.from({length: digitsCount || 1}).fill(/\d/), ]; }, }; Object.entries(testCases).forEach(([title, mask]) => { describe(`mask expression contains leading characters – ${title}`, () => { const options: Required = { ...MASKITO_DEFAULT_OPTIONS, mask, overwriteMode: 'replace', }; it('$1|2 => Type A => $1|2', () => { const value = '$12'; const selection = [2, 2] as const; const maskModel = new MaskModel({value, selection}, options); expect(() => maskModel.addCharacters('q')).toThrow(); expect(maskModel.value).toBe(value); expect(maskModel.selection).toEqual(selection); }); it('$|12 => Type $ => $|12', () => { const value = '$12'; const selection = [1, 1] as const; const maskModel = new MaskModel({value, selection}, options); expect(() => maskModel.addCharacters('$')).toThrow(); expect(maskModel.value).toBe(value); expect(maskModel.selection).toEqual(selection); }); it('$|12 => Type X => $|12', () => { const value = '$12'; const selection = [1, 1] as const; const maskModel = new MaskModel({value, selection}, options); expect(() => maskModel.addCharacters('X')).toThrow(); expect(maskModel.value).toBe(value); expect(maskModel.selection).toEqual(selection); }); }); }); }); describe('Dynamic mask expression + trailing fixed character', () => { const postfix = 'left'; const timeMask: ReadonlyArray = [/\d/, /\d/, ':', /\d/, /\d/]; const timeWithPostfixMask: Required = { ...MASKITO_DEFAULT_OPTIONS, mask: ({value}) => { let digitsCount = Math.min(value.replaceAll(/\D/g, '').length, 4); const afterLastDigit = timeMask.findIndex((x) => typeof x !== 'string' && !--digitsCount) + 1; return afterLastDigit ? timeMask.slice(0, afterLastDigit).concat(...postfix) : []; }, overwriteMode: 'replace', }; it('adds trailing postfix on enter of digit inside empty textfield', () => { const maskModel = new MaskModel( { selection: [0, 0], value: '', }, timeWithPostfixMask, ); maskModel.addCharacters('1'); expect(maskModel.value).toBe('1left'); expect(maskModel.selection).toEqual([1, 1]); }); it('adds trailing postfix on paste of many digits inside empty textfield', () => { const maskModel = new MaskModel( { selection: [0, 0], value: '', }, timeWithPostfixMask, ); maskModel.addCharacters('123'); expect(maskModel.value).toBe('12:3left'); expect(maskModel.selection).toEqual(['12:3'.length, '12:3'.length]); }); it('edits digit in the middle', () => { const maskModel = new MaskModel( { selection: [1, 1], value: '12:3left', }, timeWithPostfixMask, ); maskModel.addCharacters('4'); expect(maskModel.value).toBe('14:3left'); expect(maskModel.selection).toEqual([3, 3]); }); it('erases the last digit without losing trailing fixed characters', () => { const maskModel = new MaskModel( { selection: [3, 4], value: '12:3left', }, timeWithPostfixMask, ); maskModel.deleteCharacters(); expect(maskModel.value).toBe('12left'); expect(maskModel.selection).toEqual([2, 2]); }); it('erases the digit in the middle without losing trailing fixed character', () => { const maskModel = new MaskModel( { selection: [3, 4], value: '12:34 left', }, timeWithPostfixMask, ); maskModel.deleteCharacters(); expect(maskModel.value).toBe('12:4left'); expect(maskModel.selection).toEqual([3, 3]); }); }); it('attempt to enter invalid character at the position of fixed character', () => { const dateMask: Required = { ...MASKITO_DEFAULT_OPTIONS, mask: [/\d/, /\d/, '.', /\d/, /\d/], }; const selection = [2, 2] as const; const maskModel = new MaskModel( { selection, value: '12', }, dateMask, ); expect(() => maskModel.addCharacters('#')).toThrow(); expect(maskModel.value).toBe('12'); expect(maskModel.selection).toEqual(selection); }); it('accepts valid character at the position of fixed character', () => { const dateMask: Required = { ...MASKITO_DEFAULT_OPTIONS, mask: [/\d/, /\d/, ':', /\d/, /\d/], }; const selection = [2, 2] as const; const maskModel = new MaskModel( { selection, value: '12', }, dateMask, ); maskModel.addCharacters(':'); expect(maskModel.value).toBe('12:'); expect(maskModel.selection).toEqual(['12:'.length, '12:'.length]); }); }); ================================================ FILE: projects/core/src/lib/classes/mask-model/utils/apply-overwrite-mode.ts ================================================ import type {ElementState, MaskitoOptions} from '../../../types'; export function applyOverwriteMode( {value, selection}: ElementState, newCharacters: string, mode: MaskitoOptions['overwriteMode'], ): ElementState { const [from, to] = selection; const computedMode = typeof mode === 'function' ? mode({value, selection}) : mode; return { value, selection: computedMode === 'replace' ? [from, Math.max(from + newCharacters.length, to)] : [from, to], }; } ================================================ FILE: projects/core/src/lib/classes/mask-model/utils/calibrate-value-by-mask.ts ================================================ import type {ElementState, MaskitoMaskExpression} from '../../../types'; import {guessValidValueByPattern} from './guess-valid-value-by-pattern'; import {guessValidValueByRegExp} from './guess-valid-value-by-reg-exp'; import {validateValueWithMask} from './validate-value-with-mask'; export function calibrateValueByMask( elementState: ElementState, mask: MaskitoMaskExpression, initialElementState: ElementState | null = null, ): ElementState { if (validateValueWithMask(elementState.value, mask)) { return elementState; } const {value, selection} = Array.isArray(mask) ? guessValidValueByPattern(elementState, mask, initialElementState) : guessValidValueByRegExp(elementState, mask); return { selection, value: Array.isArray(mask) ? value.slice(0, mask.length) : value, }; } ================================================ FILE: projects/core/src/lib/classes/mask-model/utils/get-leading-fixed-characters.ts ================================================ import type {ElementState} from '../../../types'; import {isFixedCharacter} from './is-fixed-character'; export function getLeadingFixedCharacters( mask: Array, validatedValuePart: string, newCharacter: string, initialElementState: ElementState | null, ): string { let leadingFixedCharacters = ''; for (let i = validatedValuePart.length; i < mask.length; i++) { const charConstraint = mask[i] || ''; const isInitiallyExisted = initialElementState?.value[i] === charConstraint; if ( !isFixedCharacter(charConstraint) || (charConstraint === newCharacter && !isInitiallyExisted) ) { return leadingFixedCharacters; } leadingFixedCharacters += charConstraint; } return leadingFixedCharacters; } ================================================ FILE: projects/core/src/lib/classes/mask-model/utils/guess-valid-value-by-pattern.ts ================================================ import type {ElementState} from '../../../types'; import {getLeadingFixedCharacters} from './get-leading-fixed-characters'; import {isFixedCharacter} from './is-fixed-character'; import {validateValueWithMask} from './validate-value-with-mask'; export function guessValidValueByPattern( elementState: ElementState, mask: Array, initialElementState: ElementState | null, ): ElementState { let maskedFrom: number | null = null; let maskedTo: number | null = null; const maskedValue = Array.from(elementState.value).reduce( (validatedCharacters, char, charIndex) => { const leadingCharacters = getLeadingFixedCharacters( mask, validatedCharacters, char, initialElementState, ); const newValidatedChars = `${validatedCharacters}${leadingCharacters}`; const charConstraint = mask[newValidatedChars.length] || ''; if (maskedFrom === null && charIndex >= elementState.selection[0]) { maskedFrom = newValidatedChars.length; } if (maskedTo === null && charIndex >= elementState.selection[1]) { maskedTo = newValidatedChars.length; } if (isFixedCharacter(charConstraint)) { return `${newValidatedChars}${charConstraint}`; } if (char.match(charConstraint)) { return `${newValidatedChars}${char}`; } return leadingCharacters.startsWith(char) ? newValidatedChars : validatedCharacters; }, '', ); const trailingFixedCharacters = getLeadingFixedCharacters( mask, maskedValue, '', initialElementState, ); return { value: validateValueWithMask(`${maskedValue}${trailingFixedCharacters}`, mask) ? `${maskedValue}${trailingFixedCharacters}` : maskedValue, // issues: https://github.com/typescript-eslint/typescript-eslint/issues/12069 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition selection: [maskedFrom ?? maskedValue.length, maskedTo ?? maskedValue.length], }; } ================================================ FILE: projects/core/src/lib/classes/mask-model/utils/guess-valid-value-by-reg-exp.ts ================================================ import type {ElementState} from '../../../types'; export function guessValidValueByRegExp( {value, selection}: ElementState, maskRegExp: RegExp, ): ElementState { const [from, to] = selection; let newFrom = from; let newTo = to; const validatedValue = Array.from(value).reduce((validatedValuePart, char, i) => { const newPossibleValue = `${validatedValuePart}${char}`; if (from === i) { newFrom = validatedValuePart.length; } if (to === i) { newTo = validatedValuePart.length; } return newPossibleValue.match(maskRegExp) ? newPossibleValue : validatedValuePart; }, ''); return { value: validatedValue, selection: [ Math.min(newFrom, validatedValue.length), Math.min(newTo, validatedValue.length), ], }; } ================================================ FILE: projects/core/src/lib/classes/mask-model/utils/is-fixed-character.ts ================================================ export function isFixedCharacter(char: RegExp | string): char is string { return typeof char === 'string'; } ================================================ FILE: projects/core/src/lib/classes/mask-model/utils/remove-fixed-mask-characters.ts ================================================ import type {ElementState, MaskitoMaskExpression} from '../../../types'; import {isFixedCharacter} from './is-fixed-character'; export function removeFixedMaskCharacters( initialElementState: ElementState, mask: MaskitoMaskExpression, ): ElementState { if (!Array.isArray(mask)) { return initialElementState; } const [from, to] = initialElementState.selection; const selection: number[] = []; const unmaskedValue = Array.from(initialElementState.value).reduce( (rawValue, char, i) => { const charConstraint = mask[i] || ''; if (i === from) { selection.push(rawValue.length); } if (i === to) { selection.push(rawValue.length); } return isFixedCharacter(charConstraint) && charConstraint === char ? rawValue : `${rawValue}${char}`; }, '', ); if (selection.length < 2) { selection.push( ...Array.from({length: 2 - selection.length}).fill( unmaskedValue.length, ), ); } return { value: unmaskedValue, selection: [selection[0]!, selection[1]!], }; } ================================================ FILE: projects/core/src/lib/classes/mask-model/utils/validate-value-with-mask.ts ================================================ import type {MaskitoMaskExpression} from '../../../types'; import {isFixedCharacter} from './is-fixed-character'; export function validateValueWithMask( value: string, maskExpression: MaskitoMaskExpression, ): boolean { if (Array.isArray(maskExpression)) { return ( value.length === maskExpression.length && Array.from(value).every((char, i) => { const charConstraint = maskExpression[i] || ''; return isFixedCharacter(charConstraint) ? char === charConstraint : char.match(charConstraint); }) ); } return maskExpression.test(value); } ================================================ FILE: projects/core/src/lib/constants/default-element-predicate.ts ================================================ import type {MaskitoElementPredicate} from '../types'; import {maskitoAdaptContentEditable} from '../utils/content-editable'; export const MASKITO_DEFAULT_ELEMENT_PREDICATE: MaskitoElementPredicate = (e) => e.isContentEditable ? maskitoAdaptContentEditable(e) : e.querySelector('input,textarea') || (e as HTMLInputElement | HTMLTextAreaElement); ================================================ FILE: projects/core/src/lib/constants/default-options.ts ================================================ import type {MaskitoOptions} from '../types'; export const MASKITO_DEFAULT_OPTIONS: Required = { mask: /^.*$/, preprocessors: [], postprocessors: [], plugins: [], overwriteMode: 'shift', }; ================================================ FILE: projects/core/src/lib/constants/index.ts ================================================ export * from './default-element-predicate'; export * from './default-options'; ================================================ FILE: projects/core/src/lib/mask.ts ================================================ import {MaskHistory, MaskModel} from './classes'; import {MASKITO_DEFAULT_OPTIONS} from './constants'; import {createBrokenDefaultPlugin, createDoubleSpacePlugin} from './plugins'; import type { ElementState, MaskitoElement, MaskitoOptions, SelectionRange, TypedInputEvent, } from './types'; import { areElementStatesEqual, areElementValuesEqual, EventListener, getLineSelection, getNotEmptySelection, getWordSelection, isRedo, isUndo, maskitoPipe, maskitoTransform, } from './utils'; const BUILT_IN_PLUGINS = [createDoubleSpacePlugin(), createBrokenDefaultPlugin()]; export class Maskito extends MaskHistory { private readonly isTextArea = this.element.nodeName === 'TEXTAREA'; private readonly eventListener = new EventListener(this.element); private readonly options: Required = { ...MASKITO_DEFAULT_OPTIONS, ...this.maskitoOptions, }; private upcomingElementState: ElementState | null = null; private readonly preprocessor = maskitoPipe(this.options.preprocessors); private readonly postprocessor = maskitoPipe(this.options.postprocessors); private readonly teardowns = this.options.plugins .concat(BUILT_IN_PLUGINS) .map((plugin) => plugin(this.element, this.options)); constructor( private readonly element: MaskitoElement, private readonly maskitoOptions: MaskitoOptions, ) { super(); this.updateHistory(this.elementState); this.eventListener.listen('keydown', (event) => { if (isRedo(event)) { event.preventDefault(); return this.redo(); } if (isUndo(event)) { event.preventDefault(); return this.undo(); } }); this.eventListener.listen('beforeinput', (event) => { const isForward = event.inputType.includes('Forward'); this.updateHistory(this.elementState); switch (event.inputType) { case 'deleteByCut': case 'deleteContentBackward': case 'deleteContentForward': return this.handleDelete({ event, isForward, selection: getNotEmptySelection(this.elementState, isForward), }); case 'deleteHardLineBackward': case 'deleteHardLineForward': case 'deleteSoftLineBackward': case 'deleteSoftLineForward': return this.handleDelete({ event, isForward, selection: getLineSelection(this.elementState, isForward), force: true, }); case 'deleteWordBackward': case 'deleteWordForward': return this.handleDelete({ event, isForward, selection: getWordSelection(this.elementState, isForward), }); case 'historyRedo': event.preventDefault(); return this.redo(); // historyUndo/historyRedo will not be triggered if value was modified programmatically case 'historyUndo': event.preventDefault(); return this.undo(); case 'insertCompositionText': return; // will be handled inside `compositionend` event case 'insertLineBreak': case 'insertParagraph': return this.handleEnter(event); case 'insertReplacementText': /** * According {@link https://www.w3.org/TR/input-events-2 W3C specification}: * > `insertReplacementText` – insert or replace existing text by means of a spell checker, * > auto-correct, writing suggestions or similar. * ___ * Firefox emits `insertReplacementText` event for its suggestion/autofill and for spell checker. * However, it is impossible to detect which part of the textfield value is going to be replaced * (`selectionStart` and `selectionEnd` just equal to the last caret position). * ___ * Chrome does not fire `beforeinput` event for its suggestion/autofill. * It emits only `input` event with `inputType` and `data` set to `undefined`. * ___ * All these browser limitations make us to validate the result value later in `input` event. */ return; case 'insertFromDrop': case 'insertFromPaste': case 'insertText': default: return this.handleInsert( event, event.data ?? // `event.data` for `contentEditable` is always `null` for paste/drop events event.dataTransfer?.getData('text/plain') ?? '', ); } }); this.eventListener.listen( 'input', () => { if ( this.upcomingElementState && !areElementStatesEqual(this.upcomingElementState, this.elementState) ) { this.updateElementState(this.upcomingElementState); } this.upcomingElementState = null; }, {capture: true}, ); this.eventListener.listen('input', ({inputType}) => { if (inputType === 'insertCompositionText') { return; // will be handled inside `compositionend` event } this.ensureValueFitsMask(); this.updateHistory(this.elementState); }); this.eventListener.listen('compositionend', () => { this.ensureValueFitsMask(); this.updateHistory(this.elementState); }); } public destroy(): void { this.eventListener.destroy(); this.teardowns.forEach((teardown) => teardown?.()); } protected updateElementState( {value, selection}: ElementState, eventInit?: Pick, ): void { const initialValue = this.elementState.value; this.updateValue(value); this.updateSelectionRange(selection); if (eventInit && initialValue !== value) { this.dispatchInputEvent(eventInit); } } private get elementState(): ElementState { const {value, selectionStart, selectionEnd} = this.element; return { value, selection: [selectionStart ?? 0, selectionEnd ?? 0], }; } private get maxLength(): number { const {maxLength} = this.element; return maxLength === -1 ? Infinity : maxLength; } private updateSelectionRange([from, to]: SelectionRange): void { const {element} = this; if ( element.matches(':focus') && (element.selectionStart !== from || element.selectionEnd !== to) ) { element.setSelectionRange(from, to); } } private updateValue(value: string): void { /** * Don't "disturb" unnecessarily `value`-setter * (i.e. it breaks React controlled input behavior) */ if (this.element.value !== value || this.element.isContentEditable) { this.element.value = value; } } private ensureValueFitsMask(): void { this.updateElementState(maskitoTransform(this.elementState, this.options), { inputType: 'insertText', data: null, }); } private dispatchInputEvent( eventInit: Pick = { inputType: 'insertText', data: null, }, ): void { this.element.dispatchEvent( new InputEvent('input', { ...eventInit, bubbles: true, cancelable: false, }), ); } private handleDelete({ event, selection, isForward, }: { event: TypedInputEvent; selection: SelectionRange; isForward: boolean; force?: boolean; }): void { const initialState: ElementState = { value: this.elementState.value, selection, }; const {elementState} = this.preprocessor( { elementState: initialState, data: '', }, isForward ? 'deleteForward' : 'deleteBackward', ); const maskModel = new MaskModel(elementState, this.options); maskModel.deleteCharacters(); const newElementState = this.postprocessor(maskModel, initialState); if ( areElementValuesEqual(initialState, elementState, maskModel, newElementState) ) { const [from, to] = elementState.selection; event.preventDefault(); // User presses Backspace/Delete for the fixed value return this.updateSelectionRange(isForward ? [to, to] : [from, from]); } this.upcomingElementState = newElementState; } private handleInsert(event: TypedInputEvent, data: string): void { const {options, maxLength, elementState: initialElementState} = this; const [from, to] = initialElementState.selection; const {elementState, data: insertedText = data} = this.preprocessor( { data, elementState: initialElementState, }, 'insert', ); const maskModel = new MaskModel(elementState, options); try { maskModel.addCharacters(insertedText); } catch { return event.preventDefault(); } this.upcomingElementState = this.clampState( this.postprocessor(maskModel, initialElementState), ); /** * When textfield value length is already equal to attribute `maxlength`, * pressing any key (even with valid value) does not emit `input` event * (except to the case when user replaces some characters by selection). */ const noInputEventDispatch = initialElementState.value.length >= maxLength && from === to; if (noInputEventDispatch) { if ( options.overwriteMode === 'replace' && !areElementStatesEqual(this.upcomingElementState, initialElementState) ) { this.dispatchInputEvent({inputType: 'insertText', data}); } else { /** * This `beforeinput` event will not be followed by `input` event – * clear computed state to avoid any possible side effect * for new possible `input` event without preceding `beforeinput` event * (e.g. browser autofill, `document.execCommand('delete')` etc.) */ this.upcomingElementState = null; } } } private handleEnter(event: TypedInputEvent): void { if (this.isTextArea || this.element.isContentEditable) { this.handleInsert(event, '\n'); } } private clampState({value, selection}: ElementState): ElementState { const [from, to] = selection; const max = this.maxLength; return { value: value.slice(0, max), selection: [Math.min(from, max), Math.min(to, max)], }; } } ================================================ FILE: projects/core/src/lib/plugins/broken-prevent-default.plugin.ts ================================================ import type {MaskitoPlugin} from '@maskito/core'; import type {TypedInputEvent} from '../types'; import {EventListener} from '../utils'; /** * All `input` events with `inputType=deleteContentBackward` always follows `beforeinput` event with the same `inputType`. * If `beforeinput[inputType=deleteContentBackward]` is prevented, subsequent `input[inputType=deleteContentBackward]` is prevented too. * There is an exception – Android devices with Microsoft SwiftKey Keyboard in Mobile Chrome. * These devices ignores `preventDefault` for `beforeinput` event if Backspace is pressed. * @see https://github.com/taiga-family/maskito/issues/2135#issuecomment-2980729647 * ___ * TODO: track Chromium bug report and delete this plugin after bug fix * https://issues.chromium.org/issues/40885402 */ export function createBrokenDefaultPlugin(): MaskitoPlugin { return (element) => { const eventListener = new EventListener(element); let isVirtualAndroidKeyboard = false; let beforeinputEvent: TypedInputEvent; let value = element.value; eventListener.listen('keydown', ({key}) => { isVirtualAndroidKeyboard = key === 'Unidentified'; }); eventListener.listen('beforeinput', (event) => { beforeinputEvent = event; value = element.value; }); eventListener.listen( 'input', (event) => { if ( isVirtualAndroidKeyboard && beforeinputEvent.defaultPrevented && beforeinputEvent.inputType === 'deleteContentBackward' && event.inputType === 'deleteContentBackward' ) { element.value = value; } }, {capture: true}, ); return () => eventListener.destroy(); }; } ================================================ FILE: projects/core/src/lib/plugins/change-event-plugin.ts ================================================ import type {MaskitoPlugin} from '../types'; export function maskitoChangeEventPlugin(): MaskitoPlugin { return (element) => { if (element.isContentEditable) { return; } let value = element.value; const valueListener = (): void => { value = element.value; }; const blurListener = (): void => { if (element.value !== value) { element.dispatchEvent(new Event('change', {bubbles: true})); } }; element.addEventListener('focus', valueListener); element.addEventListener('change', valueListener); element.addEventListener('blur', blurListener); return () => { element.removeEventListener('focus', valueListener); element.removeEventListener('change', valueListener); element.removeEventListener('blur', blurListener); }; }; } ================================================ FILE: projects/core/src/lib/plugins/double-space.plugin.ts ================================================ import type {MaskitoPlugin} from '@maskito/core'; import type {TypedInputEvent} from '../types'; import {EventListener} from '../utils'; const SPACE = ' '; /** * 1. Android user (with G-board keyboard or similar) presses 1st space * ``` * {type: "beforeinput", data: " ", inputType: "insertText"} * ``` * 2. User presses 2nd space * ``` * // Android tries to delete previously inserted space * {type: "beforeinput", inputType: "deleteContentBackward"} * {type: "beforeinput", data: ". ", inputType: "insertText"} * ``` * --------- * 1. MacOS user presses 1st space * ``` * {type: "beforeinput", data: " ", inputType: "insertText"} * ``` * 2. User presses 2nd space * ``` * // MacOS automatically run `element.setSelectionRange(indexBeforeSpace, indexAfterSpace)` and then * {type: "beforeinput", data: ". ", inputType: "insertText"} * ``` * --------- * @see https://github.com/taiga-family/maskito/issues/2023 */ export function createDoubleSpacePlugin(): MaskitoPlugin { let prevValue = ''; let prevCaretIndex = 0; let prevEvent: TypedInputEvent | null = null; let prevRejectedSpace = false; return (element) => { const eventListener = new EventListener(element); eventListener.listen('beforeinput', (event) => { const {value, selectionStart, selectionEnd} = element; const rejectedSpace = prevEvent?.inputType === 'insertText' && prevEvent.data === SPACE && !value.slice(0, Number(selectionEnd)).endsWith(SPACE); if (event.inputType === 'insertText' && event.data === `.${SPACE}`) { if ( prevEvent?.inputType === 'deleteContentBackward' && prevRejectedSpace ) { // Android element.value = prevValue; element.setSelectionRange(prevCaretIndex, prevCaretIndex); } else if (rejectedSpace) { // Mac OS element.setSelectionRange(selectionStart, selectionStart); } } prevRejectedSpace = rejectedSpace; prevEvent = event; prevValue = value; prevCaretIndex = Number( (rejectedSpace ? prevCaretIndex : selectionEnd) === value.length ? selectionEnd : selectionStart, ); }); return () => eventListener.destroy(); }; } ================================================ FILE: projects/core/src/lib/plugins/index.ts ================================================ export * from './broken-prevent-default.plugin'; export * from './change-event-plugin'; export * from './double-space.plugin'; export * from './initial-calibration-plugin'; export * from './strict-composition-plugin'; ================================================ FILE: projects/core/src/lib/plugins/initial-calibration-plugin.ts ================================================ import type {MaskitoOptions, MaskitoPlugin} from '../types'; import {maskitoTransform, maskitoUpdateElement} from '../utils'; export function maskitoInitialCalibrationPlugin( customOptions?: MaskitoOptions, ): MaskitoPlugin { return (element, options) => { const from = element.selectionStart ?? 0; const to = element.selectionEnd ?? 0; maskitoUpdateElement(element, { value: maskitoTransform(element.value, customOptions || options), selection: [from, to], }); }; } ================================================ FILE: projects/core/src/lib/plugins/strict-composition-plugin.ts ================================================ import type {ElementState, MaskitoPlugin, TypedInputEvent} from '../types'; import {areElementStatesEqual, maskitoTransform, maskitoUpdateElement} from '../utils'; export function maskitoStrictCompositionPlugin(): MaskitoPlugin { return (element, maskitoOptions) => { const listener = (event: TypedInputEvent): void => { if (event.inputType !== 'insertCompositionText') { return; } const selection = [ element.selectionStart ?? 0, element.selectionEnd ?? 0, ] as const; const elementState: ElementState = { selection, value: element.value, }; const validatedState = maskitoTransform(elementState, maskitoOptions); if (!areElementStatesEqual(elementState, validatedState)) { event.preventDefault(); maskitoUpdateElement(element, validatedState); } }; element.addEventListener('input', listener as EventListener); return () => element.removeEventListener('input', listener as EventListener); }; } ================================================ FILE: projects/core/src/lib/types/element-predicate.ts ================================================ import type {MaskitoElement} from './maskito-element'; export type MaskitoElementPredicate = ( element: HTMLElement, ) => MaskitoElement | Promise; ================================================ FILE: projects/core/src/lib/types/element-state.ts ================================================ import type {SelectionRange} from './selection-range'; export interface ElementState { readonly value: string; readonly selection: SelectionRange; } ================================================ FILE: projects/core/src/lib/types/index.ts ================================================ export * from './element-predicate'; export * from './element-state'; export * from './mask'; export * from './mask-options'; export * from './mask-processors'; export * from './maskito-element'; export * from './plugin'; export * from './selection-range'; export * from './typed-input-event'; ================================================ FILE: projects/core/src/lib/types/mask-options.ts ================================================ import type {ElementState} from './element-state'; import type {MaskitoMask} from './mask'; import type {MaskitoPostprocessor, MaskitoPreprocessor} from './mask-processors'; import type {MaskitoPlugin} from './plugin'; export interface MaskitoOptions { readonly mask: MaskitoMask; readonly preprocessors?: readonly MaskitoPreprocessor[]; readonly postprocessors?: readonly MaskitoPostprocessor[]; readonly plugins?: readonly MaskitoPlugin[]; readonly overwriteMode?: | 'replace' | 'shift' | ((elementState: ElementState) => 'replace' | 'shift'); } ================================================ FILE: projects/core/src/lib/types/mask-processors.ts ================================================ import type {ElementState} from './element-state'; export type MaskitoPreprocessor = ( _: { elementState: ElementState; data: string; }, actionType: 'deleteBackward' | 'deleteForward' | 'insert' | 'validation', ) => { elementState: ElementState; data?: string; }; export type MaskitoPostprocessor = ( elementState: ElementState, initialElementState: ElementState, ) => ElementState; ================================================ FILE: projects/core/src/lib/types/mask.ts ================================================ import type {ElementState} from './element-state'; export type MaskitoMaskExpression = Array | RegExp; export type MaskitoMask = | MaskitoMaskExpression | ((elementState: ElementState) => MaskitoMaskExpression); ================================================ FILE: projects/core/src/lib/types/maskito-element.ts ================================================ export type TextfieldLike = Pick< HTMLInputElement, | 'maxLength' | 'select' | 'selectionEnd' | 'selectionStart' | 'setSelectionRange' | 'value' >; export type MaskitoElement = HTMLElement & TextfieldLike; ================================================ FILE: projects/core/src/lib/types/plugin.ts ================================================ import type {MaskitoOptions} from './mask-options'; import type {MaskitoElement} from './maskito-element'; export type MaskitoPlugin = ( element: MaskitoElement, options: Required, ) => (() => void) | void; ================================================ FILE: projects/core/src/lib/types/selection-range.ts ================================================ export type SelectionRange = readonly [from: number, to: number]; ================================================ FILE: projects/core/src/lib/types/typed-input-event.ts ================================================ export interface TypedInputEvent extends InputEvent { inputType: | 'deleteByCut' // Ctrl (Command) + X | 'deleteContentBackward' // Backspace | 'deleteContentForward' // Delete (Fn + Backspace) | 'deleteHardLineBackward' // Ctrl (Command) + Backspace | 'deleteHardLineForward' | 'deleteSoftLineBackward' // Ctrl (Command) + Backspace | 'deleteSoftLineForward' | 'deleteWordBackward' // Alt (Option) + Backspace | 'deleteWordForward' // Alt (Option) + Delete (Fn + Backspace) | 'historyRedo' // Ctrl (Command) + Shift + Z | 'historyUndo' // Ctrl (Command) + Z | 'insertCompositionText' | 'insertFromDrop' | 'insertFromPaste' // Ctrl (Command) + V | 'insertLineBreak' | 'insertParagraph' | 'insertReplacementText' | 'insertText'; } ================================================ FILE: projects/core/src/lib/utils/content-editable.ts ================================================ import type {MaskitoElement, TextfieldLike} from '../types'; import {getContentEditableSelection} from './dom/get-content-editable-selection'; import {setContentEditableSelection} from './dom/set-content-editable-selection'; class ContentEditableAdapter implements TextfieldLike { public maxLength = Infinity; constructor(private readonly element: HTMLElement) {} public get value(): string { return this.element.innerText.replace(/\n\n$/, '\n'); } public set value(value) { // Setting into innerHTML of element with `white-space: pre;` style this.element.innerHTML = value.replace(/\n$/, '\n\n'); } public get selectionStart(): number | null { return getContentEditableSelection(this.element)[0]; } public get selectionEnd(): number | null { return getContentEditableSelection(this.element)[1]; } public setSelectionRange(from: number | null, to: number | null): void { setContentEditableSelection(this.element, [from ?? 0, to ?? 0]); } public select(): void { this.setSelectionRange(0, this.value.length); } } export function maskitoAdaptContentEditable(element: HTMLElement): MaskitoElement { const adapter = new ContentEditableAdapter(element); return new Proxy(element, { get(target, prop: keyof HTMLElement) { if (prop in adapter) { return adapter[prop as keyof ContentEditableAdapter]; } const nativeProperty = target[prop]; return typeof nativeProperty === 'function' ? nativeProperty.bind(target) : nativeProperty; }, set(target, prop: keyof HTMLElement, val, receiver) { return Reflect.set(prop in adapter ? adapter : target, prop, val, receiver); }, }) as MaskitoElement; } ================================================ FILE: projects/core/src/lib/utils/dom/event-listener.ts ================================================ import type {TypedInputEvent} from '../../types'; export class EventListener { private readonly listeners: Array<() => void> = []; constructor(private readonly element: HTMLElement) {} public listen( eventType: E, fn: ( event: E extends 'beforeinput' | 'input' ? TypedInputEvent : HTMLElementEventMap[E], ) => unknown, options?: AddEventListenerOptions, ): void { const untypedFn = fn as (event: HTMLElementEventMap[E]) => unknown; this.element.addEventListener(eventType, untypedFn, options); this.listeners.push(() => this.element.removeEventListener(eventType, untypedFn, options), ); } public destroy(): void { this.listeners.forEach((stopListen) => stopListen()); } } ================================================ FILE: projects/core/src/lib/utils/dom/get-content-editable-selection.ts ================================================ import type {SelectionRange} from '../../types'; export function getContentEditableSelection(element: HTMLElement): SelectionRange { const {anchorOffset = 0, focusOffset = 0} = element.ownerDocument.getSelection() || {}; const from = Math.min(anchorOffset, focusOffset); const to = Math.max(anchorOffset, focusOffset); return [from, to]; } ================================================ FILE: projects/core/src/lib/utils/dom/history-events.ts ================================================ import {HotkeyCode, HotkeyModifier, isHotkey} from './hotkey'; export function isRedo(event: KeyboardEvent): boolean { return ( isHotkey(event, HotkeyModifier.CTRL, HotkeyCode.Y) || // Windows isHotkey(event, HotkeyModifier.CTRL | HotkeyModifier.SHIFT, HotkeyCode.Z) || // Windows & Android isHotkey(event, HotkeyModifier.META | HotkeyModifier.SHIFT, HotkeyCode.Z) // macOS & iOS ); } export function isUndo(event: KeyboardEvent): boolean { return ( isHotkey(event, HotkeyModifier.CTRL, HotkeyCode.Z) || // Windows & Android isHotkey(event, HotkeyModifier.META, HotkeyCode.Z) // macOS & iOS ); } ================================================ FILE: projects/core/src/lib/utils/dom/hotkey.ts ================================================ export const HotkeyModifier = { CTRL: 1 << 0, ALT: 1 << 1, SHIFT: 1 << 2, META: 1 << 3, } as const; // TODO add variants that can be processed correctly export const HotkeyCode = { Y: 89, Z: 90, } as const; /** * Checks if the passed keyboard event match the required hotkey. * * @example * input.addEventListener('keydown', (event) => { * if (isHotkey(event, HotkeyModifier.CTRL | HotkeyModifier.SHIFT, HotkeyCode.Z)) { * // redo hotkey pressed * } * }) * * @return will return `true` only if the {@link HotkeyCode} matches and only the necessary * {@link HotkeyModifier modifiers} have been pressed */ export function isHotkey( event: KeyboardEvent, modifiers: (typeof HotkeyModifier)[keyof typeof HotkeyModifier], hotkeyCode: (typeof HotkeyCode)[keyof typeof HotkeyCode], ): boolean { return ( event.ctrlKey === !!(modifiers & HotkeyModifier.CTRL) && event.altKey === !!(modifiers & HotkeyModifier.ALT) && event.shiftKey === !!(modifiers & HotkeyModifier.SHIFT) && event.metaKey === !!(modifiers & HotkeyModifier.META) && /** * We intentionally use legacy {@link KeyboardEvent#keyCode `keyCode`} property. It is more * "keyboard-layout"-independent than {@link KeyboardEvent#key `key`} or {@link KeyboardEvent#code `code`} properties. * @see {@link https://github.com/taiga-family/maskito/issues/315 `KeyboardEvent#code` issue} */ event.keyCode === hotkeyCode ); } ================================================ FILE: projects/core/src/lib/utils/dom/set-content-editable-selection.ts ================================================ import type {SelectionRange} from '../../types'; export function setContentEditableSelection( element: HTMLElement, [from, to]: SelectionRange, ): void { const document = element.ownerDocument; const range = document.createRange(); range.setStart( element.firstChild || element, Math.min(from, element.textContent?.length ?? 0), ); range.setEnd( element.lastChild || element, Math.min(to, element.textContent?.length ?? 0), ); const selection = document.getSelection(); if (selection) { selection.removeAllRanges(); selection.addRange(range); } } ================================================ FILE: projects/core/src/lib/utils/dom/update-element.ts ================================================ import type {ElementState, MaskitoElement} from '../../types'; /** * Sets value to element, and dispatches input event * if you passed ELementState, it also sets selection range * * @example * maskitoUpdateElement(input, newValue); * maskitoUpdateElement(input, elementState); * * @see {@link https://github.com/taiga-family/maskito/issues/804 issue} * * @return void */ export function maskitoUpdateElement( element: MaskitoElement, valueOrElementState: ElementState | string, ): void { const initialValue = element.value; if (typeof valueOrElementState === 'string') { element.value = valueOrElementState; } else { const [from, to] = valueOrElementState.selection; element.value = valueOrElementState.value; if (element.matches(':focus')) { element.setSelectionRange(from, to); } } if (element.value !== initialValue) { element.dispatchEvent( new Event( 'input', /** * React handles this event only on bubbling phase * * here is the list of events that are processed in the capture stage, others are processed in the bubbling stage * https://github.com/facebook/react/blob/cb2439624f43c510007f65aea5c50a8bb97917e4/packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L222 */ {bubbles: true}, ), ); } } ================================================ FILE: projects/core/src/lib/utils/element-states-equality.ts ================================================ import type {ElementState} from '../types'; export function areElementValuesEqual( sampleState: ElementState, ...states: ElementState[] ): boolean { return states.every(({value}) => value === sampleState.value); } export function areElementStatesEqual( sampleState: ElementState, ...states: ElementState[] ): boolean { return states.every( ({value, selection}) => value === sampleState.value && selection[0] === sampleState.selection[0] && selection[1] === sampleState.selection[1], ); } ================================================ FILE: projects/core/src/lib/utils/get-line-selection.ts ================================================ import type {ElementState, SelectionRange} from '../types'; export function getLineSelection( {value, selection}: ElementState, isForward: boolean, ): SelectionRange { const [from, to] = selection; if (from !== to) { return [from, to]; } const nearestBreak = isForward ? value.slice(from).indexOf('\n') + 1 || value.length : value.slice(0, to).lastIndexOf('\n') + 1; const selectFrom = isForward ? from : nearestBreak; const selectTo = isForward ? nearestBreak : to; return [selectFrom, selectTo]; } ================================================ FILE: projects/core/src/lib/utils/get-not-empty-selection.ts ================================================ import type {ElementState, SelectionRange} from '../types'; export function getNotEmptySelection( {value, selection}: ElementState, isForward: boolean, ): SelectionRange { const [from, to] = selection; if (from !== to) { return [from, to]; } const notEmptySelection = isForward ? [from, to + 1] : [from - 1, to]; return notEmptySelection.map((x) => Math.min(Math.max(x, 0), value.length)) as [ number, number, ]; } ================================================ FILE: projects/core/src/lib/utils/get-word-selection.ts ================================================ import type {ElementState, SelectionRange} from '../types'; const TRAILING_SPACES_REG = /\s+$/g; const LEADING_SPACES_REG = /^\s+/g; const SPACE_REG = /\s/; export function getWordSelection( {value, selection}: ElementState, isForward: boolean, ): SelectionRange { const [from, to] = selection; if (from !== to) { return [from, to]; } if (isForward) { const valueAfterSelectionStart = value.slice(from); const [leadingSpaces] = valueAfterSelectionStart.match(LEADING_SPACES_REG) || [ '', ]; const nearestWordEndIndex = valueAfterSelectionStart .trimStart() .search(SPACE_REG); return [ from, nearestWordEndIndex === -1 ? value.length : from + leadingSpaces.length + nearestWordEndIndex, ]; } const valueBeforeSelectionEnd = value.slice(0, to); const [trailingSpaces] = valueBeforeSelectionEnd.match(TRAILING_SPACES_REG) || ['']; const selectedWordLength = valueBeforeSelectionEnd .trimEnd() .split('') .reverse() .findIndex((char) => SPACE_REG.exec(char)); return [ selectedWordLength === -1 ? 0 : to - trailingSpaces.length - selectedWordLength, to, ]; } ================================================ FILE: projects/core/src/lib/utils/index.ts ================================================ export * from './content-editable'; export * from './dom/event-listener'; export * from './dom/get-content-editable-selection'; export * from './dom/history-events'; export * from './dom/set-content-editable-selection'; export * from './dom/update-element'; export * from './element-states-equality'; export * from './get-line-selection'; export * from './get-not-empty-selection'; export * from './get-word-selection'; export * from './pipe'; export * from './transform'; ================================================ FILE: projects/core/src/lib/utils/pipe.ts ================================================ import type {MaskitoPostprocessor, MaskitoPreprocessor} from '../types'; /** * @internal */ export function maskitoPipe( processors?: readonly MaskitoPreprocessor[], ): MaskitoPreprocessor; /** * @internal */ export function maskitoPipe( processors?: readonly MaskitoPostprocessor[], ): MaskitoPostprocessor; /** * @internal */ export function maskitoPipe( processors: ReadonlyArray<(state: State, ...args: Args) => Partial> = [], ): (state: State, ...args: Args) => State { return (initialData: State, ...args: Args) => processors.reduce((data, fn) => ({...data, ...fn(data, ...args)}), initialData); } ================================================ FILE: projects/core/src/lib/utils/test/get-not-empty-selection.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import type {ElementState} from '../../types'; import {getNotEmptySelection} from '../get-not-empty-selection'; describe('getNotEmptySelection', () => { it('should return the same selection when selection positions are not equal', () => { const elementStateStub: ElementState = { value: 'testValue', selection: [1, 3], }; expect(getNotEmptySelection(elementStateStub, true)).toEqual( elementStateStub.selection, ); expect(getNotEmptySelection(elementStateStub, false)).toEqual( elementStateStub.selection, ); }); describe('backward direction', () => { it('should decrease by one start position value', () => { const elementStateStub: ElementState = { value: 'testValue', selection: [4, 4], }; expect(getNotEmptySelection(elementStateStub, false)).toEqual([3, 4]); }); it('should not change everything when start value is 0', () => { const elementStateStub: ElementState = { value: 'testValue', selection: [0, 4], }; expect(getNotEmptySelection(elementStateStub, false)).toEqual([0, 4]); }); it('should decrease by one start value', () => { const elementStateStub: ElementState = { value: 'testValue', selection: [1, 1], }; expect(getNotEmptySelection(elementStateStub, false)).toEqual([0, 1]); }); }); describe('forward direction', () => { it('should increase by one end position, when value`s length more then end position', () => { const elementStateStub: ElementState = { value: 'testValue', selection: [2, 2], }; expect(getNotEmptySelection(elementStateStub, true)).toEqual([2, 3]); }); it('should return value length as end position, when value`s length less or equal then end position', () => { const elementStateStub: ElementState = { value: 'sx', selection: [4, 4], }; expect(getNotEmptySelection(elementStateStub, true)).toEqual([2, 2]); }); it('should increase by one end position, when value`s length equal end position increased by one', () => { const elementStateStub: ElementState = { value: 'test1', selection: [4, 4], }; expect(getNotEmptySelection(elementStateStub, true)).toEqual([4, 5]); }); }); }); ================================================ FILE: projects/core/src/lib/utils/test/get-word-selection.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {getWordSelection} from '../get-word-selection'; describe('getWordSelection', () => { describe('Backward', () => { it('"1 23 456|" => select "456" => [5,8]', () => { expect( getWordSelection( { value: '1 23 456', selection: [ 8, // '1 23 456'.length 8, ], }, false, ), ).toEqual([ 5, // '1 23 '.length 8, ]); }); it('"1 23 45|6" => select "45" => [5,7]', () => { expect( getWordSelection( { value: '1 23 456', selection: [ 7, // '1 23 45'.length 7, ], }, false, ), ).toEqual([ 5, // '1 23 '.length 7, ]); }); it('"1 23 |456" => select "23 " => [2,5]', () => { expect( getWordSelection( { value: '1 23 456', selection: [ 5, // '1 23 '.length 5, ], }, false, ), ).toEqual([ 2, // '1 '.length 5, ]); }); it('"1 23| 456" => select "23" => [2,4]', () => { expect( getWordSelection( { value: '1 23 456', selection: [ 4, // '1 23'.length 4, ], }, false, ), ).toEqual([ 2, // '1 '.length 4, ]); }); it('"|1 23 456" => select "" => [0,0]', () => { expect( getWordSelection({value: '1 23 456', selection: [0, 0]}, false), ).toEqual([0, 0]); }); it('"1 2 |3" (two spaces after 2) => select "2 " => [2,5]', () => { expect( getWordSelection( { value: '1 2 3', selection: [ 5, // space before 3 5, ], }, false, ), ).toEqual([2, 5]); }); }); describe('Forward', () => { it('"1 23 456|" => select "" => [8,8]', () => { expect( getWordSelection( { value: '1 23 456', selection: [ 8, // '1 23 456'.length 8, ], }, true, ), ).toEqual([8, 8]); }); it('"1 23 45|6" => select "6" => [7,8]', () => { expect( getWordSelection( { value: '1 23 456', selection: [ 7, // '1 23 45'.length 7, ], }, true, ), ).toEqual([7, 8]); }); it('"1 23 |456" => select "456" => [5,8]', () => { expect( getWordSelection( { value: '1 23 456', selection: [ 5, // '1 23 '.length 5, ], }, true, ), ).toEqual([5, 8]); }); it('"1 23| 456" => select " 456" => [4,8]', () => { expect( getWordSelection( { value: '1 23 456', selection: [ 4, // '1 23'.length 4, ], }, true, ), ).toEqual([4, 8]); }); it('"1 |23 456" => select "23" => [2,4]', () => { expect( getWordSelection({value: '1 23 456', selection: [2, 2]}, true), ).toEqual([2, 4]); }); it('"1| 23 456" => select " 23" => [1,4]', () => { expect( getWordSelection({value: '1 23 456', selection: [1, 1]}, true), ).toEqual([1, 4]); }); it('"1 2| 3" (two spaces after 2) => select " 3" => [3,6]', () => { expect( getWordSelection( { value: '1 2 3', selection: [3, 3], }, true, ), ).toEqual([3, 6]); }); }); }); ================================================ FILE: projects/core/src/lib/utils/test/pipe.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import type {ElementState, MaskitoPostprocessor, MaskitoPreprocessor} from '../../types'; import {maskitoPipe} from '../pipe'; describe('maskitoPipe', () => { describe('Preprocessor', () => { const preprocessorData: Parameters[0] = { elementState: {value: '2', selection: [2, 2]}, data: '0', }; const add0ToValue: MaskitoPreprocessor = ({elementState}) => ({ elementState: { ...elementState, value: `${elementState.value}0`, }, }); const add1ToValue: MaskitoPreprocessor = ({elementState}) => ({ elementState: { ...elementState, value: `${elementState.value}1`, }, }); const add5ToData: MaskitoPreprocessor = ({elementState, data}) => ({ elementState, data: `${data}5`, }); const add3ToData: MaskitoPreprocessor = ({elementState, data}) => ({ elementState, data: `${data}3`, }); it('take the last valid `data` if the previous processor did not modify it', () => { expect( maskitoPipe([add3ToData, add0ToValue, add5ToData])( preprocessorData, 'insert', ), ).toEqual({ elementState: {value: '20', selection: [2, 2]}, data: '035', }); }); describe('Order matters', () => { it('for `elementState`', () => { expect( maskitoPipe([add0ToValue, add1ToValue])(preprocessorData, 'insert'), ).toEqual({ elementState: {value: '201', selection: [2, 2]}, data: '0', }); expect( maskitoPipe([add1ToValue, add0ToValue])(preprocessorData, 'insert'), ).toEqual({ elementState: {value: '210', selection: [2, 2]}, data: '0', }); }); it('for `data`', () => { expect( maskitoPipe([add3ToData, add5ToData])(preprocessorData, 'insert'), ).toEqual({ elementState: {value: '2', selection: [2, 2]}, data: '035', }); expect( maskitoPipe([add5ToData, add3ToData])(preprocessorData, 'insert'), ).toEqual({ elementState: {value: '2', selection: [2, 2]}, data: '053', }); }); it('for `elementState` & `data` in one pipe', () => { expect( maskitoPipe([add5ToData, add0ToValue, add3ToData, add1ToValue])( preprocessorData, 'insert', ), ).toEqual({ elementState: {value: '201', selection: [2, 2]}, data: '053', }); }); }); }); describe('Postprocessor', () => { const initialElementState: Parameters[1] = { value: '', selection: [0, 0], }; const postprocessorData: Parameters[0] = { value: '0', selection: [5, 5], }; const add3: MaskitoPostprocessor = ({value, selection}) => ({ selection, value: `${value}3`, }); const add5: MaskitoPostprocessor = ({value, selection}) => ({ selection, value: `${value}5`, }); const doubleCaretIndex: MaskitoPostprocessor = ({value, selection}) => ({ value, selection: [selection[0] * 2, selection[1] * 2], }); const shiftCaretIndexBy5: MaskitoPostprocessor = ({value, selection}) => ({ value, selection: [selection[0] + 5, selection[1] + 5], }); describe('Order matters', () => { it('for `value`', () => { expect( maskitoPipe([add3, add5])(postprocessorData, initialElementState), ).toEqual({ value: '035', selection: [5, 5], }); expect( maskitoPipe([add5, add3])(postprocessorData, initialElementState), ).toEqual({ value: '053', selection: [5, 5], }); }); it('for `selection`', () => { expect( maskitoPipe([doubleCaretIndex, shiftCaretIndexBy5])( postprocessorData, initialElementState, ), ).toEqual({ value: '0', selection: [15, 15], }); expect( maskitoPipe([shiftCaretIndexBy5, doubleCaretIndex])( postprocessorData, initialElementState, ), ).toEqual({ value: '0', selection: [20, 20], }); }); it('for `value` & `selection` in one pipe', () => { expect( maskitoPipe([add5, doubleCaretIndex, add3, shiftCaretIndexBy5])( postprocessorData, initialElementState, ), ).toEqual({ value: '053', selection: [15, 15], }); }); }); }); describe('Readonly arguments are passed to all processors', () => { const elementState: ElementState = {value: '', selection: [0, 0]}; it('preprocessor', () => { const checkActionType: MaskitoPreprocessor = (data, actionType) => { expect(actionType).toBe('deleteBackward'); return data; }; maskitoPipe([checkActionType, checkActionType, checkActionType])( {elementState, data: ''}, 'deleteBackward', ); }); it('postprocessor', () => { const initialElementState: ElementState = { value: '27', selection: [2, 7], }; const checkActionType: MaskitoPostprocessor = (data, actionType) => { expect(actionType).toEqual(initialElementState); return data; }; maskitoPipe([checkActionType, checkActionType, checkActionType])( elementState, initialElementState, ); }); }); }); ================================================ FILE: projects/core/src/lib/utils/test/transform.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import type {MaskitoOptions} from '../../types'; import {maskitoTransform} from '../transform'; describe('maskitoTransform', () => { const numberOptions: MaskitoOptions = { mask: /^\d+(,\d*)?$/, preprocessors: [ ({elementState, data}) => { const {value, selection} = elementState; return { elementState: { selection, value: value.replace('.', ','), }, data: data.replace('.', ','), }; }, ], postprocessors: [ ({value, selection}) => { const newValue = value.replace(/^0+/, '0'); const deletedChars = value.length - newValue.length; const [from, to] = selection; return { value: newValue, selection: [from - deletedChars, to - deletedChars], }; }, ], }; const usPhoneOptions: MaskitoOptions = { mask: [ '+', '1', ' ', '(', /\d/, /\d/, /\d/, ')', ' ', /\d/, /\d/, /\d/, '-', /\d/, /\d/, /\d/, /\d/, ], }; describe('Basic API', () => { it('returns string if the first argument is a string', () => { expect(typeof maskitoTransform('100', numberOptions)).toBe('string'); }); it('returns ElementState if the first argument is a ElementState', () => { const res = maskitoTransform({value: '', selection: [0, 0]}, numberOptions); expect(res).toBeTruthy(); expect(typeof res).toBe('object'); }); it('formats value using preprocessor', () => { expect(maskitoTransform('100.42', numberOptions)).toBe('100,42'); expect( maskitoTransform( { value: '100.42', selection: [2, 2], }, numberOptions, ), ).toEqual({value: '100,42', selection: [2, 2]}); }); it('formats value using postprocessor', () => { expect(maskitoTransform('0000,1234', numberOptions)).toBe('0,1234'); expect( maskitoTransform( { value: '0000,1234', selection: [6, 6], }, numberOptions, ), ).toEqual({value: '0,1234', selection: [3, 3]}); }); it('drops invalid characters (mask expression works)', () => { expect(maskitoTransform('42Taiga UI42', numberOptions)).toBe('4242'); expect( maskitoTransform( { value: '42Taiga UI42', selection: [11, 11], }, numberOptions, ), ).toEqual({value: '4242', selection: [3, 3]}); }); }); describe('Drop / Browser autofill cases', () => { it('`US` Phone mask | Drops "+1(21"', () => { expect(maskitoTransform('+1(21', usPhoneOptions)).toBe('+1 (21'); }); }); }); ================================================ FILE: projects/core/src/lib/utils/transform.ts ================================================ import {MaskModel} from '../classes'; import {MASKITO_DEFAULT_OPTIONS} from '../constants'; import type {ElementState, MaskitoOptions} from '../types'; import {maskitoPipe} from './pipe'; export function maskitoTransform(value: string, maskitoOptions: MaskitoOptions): string; export function maskitoTransform( state: ElementState, maskitoOptions: MaskitoOptions, ): ElementState; export function maskitoTransform( valueOrState: ElementState | string, maskitoOptions: MaskitoOptions, ): ElementState | string { const options: Required = { ...MASKITO_DEFAULT_OPTIONS, ...maskitoOptions, }; const preprocessor = maskitoPipe(options.preprocessors); const postprocessor = maskitoPipe(options.postprocessors); const initialElementState = typeof valueOrState === 'string' ? {value: valueOrState, selection: [0, 0] as const} : valueOrState; const {elementState} = preprocessor( {elementState: initialElementState, data: ''}, 'validation', ); const maskModel = new MaskModel(elementState, options); const {value, selection} = postprocessor(maskModel, initialElementState); return typeof valueOrState === 'string' ? value : {value, selection}; } ================================================ FILE: projects/demo/.gitignore ================================================ # compiled output /dist /tmp /out-tsc # Only exists if Bazel was run /bazel-out # dependencies /node_modules # profiling files chrome-profiler-events.json speed-measure-plugin.json # IDEs and editors /.idea .project .classpath .c9/ *.launch .settings/ *.sublime-workspace # IDE - VSCode .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json .history/* # misc /.sass-cache /connect.lock /coverage /libpeerconnection.log npm-debug.log yarn-error.log testem.log /typings # System Files .DS_Store Thumbs.db ================================================ FILE: projects/demo/esbuild-plugins/maskito-as-taiga-ui-dep.plugin.js ================================================ const {resolve, join} = require('node:path'); const {existsSync} = require('node:fs'); /** * Maskito repo uses Taiga UI to build demo application. * Taiga UI uses Maskito packages as a dependency. * Maskito <=> Taiga UI circular dependency. * ESBuild is capable to use path aliases from tsconfig, to resolve local imports * (e.g. source code of @maskito/kit imports @maskito/core utility). * However, ESBuild ignores tsconfig paths when resolving packages inside node_modules. */ module.exports = { name: 'maskito-as-taiga-ui-dep', setup(build) { build.onResolve({filter: /^@maskito/}, (args) => { if (!args.importer.includes(join('node_modules', '@taiga-ui'))) { // Ignore for local path aliases (ESBuild handles them properly) return; } const library = args.path // e.g., '@maskito/kit' .split('/')[1]; const entryPoint = resolve( __dirname, '../../../projects', library, 'src/index.ts', ); return existsSync(entryPoint) ? { path: entryPoint, } : null; }); }, }; ================================================ FILE: projects/demo/esbuild-plugins/vue-esm.plugin.js ================================================ const {resolve} = require('node:path'); /** * Otherwise, demo application logs warning: * ``` * Component provided template option but runtime compilation is not supported in this build of Vue. * Configure your bundler to alias "vue" to "vue/dist/vue.esm-bundler.js"`. * ``` * Equivalent to webpack's alias: { vue$: 'vue/dist/vue.esm-bundler.js' } */ module.exports = { name: 'vue-esm', setup(build) { build.onResolve({filter: /^vue$/}, () => ({ path: resolve( build.initialOptions.absWorkingDir, './node_modules/vue/dist/vue.esm-bundler.js', ), })); }, }; ================================================ FILE: projects/demo/jest.config.ts ================================================ export default { displayName: 'demo', preset: '../../jest.preset.js', setupFilesAfterEnv: ['/src/test-setup.ts'], coverageDirectory: '../../coverage/demo', transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'], snapshotSerializers: [ 'jest-preset-angular/build/serializers/no-ng-attributes', 'jest-preset-angular/build/serializers/ng-snapshot', 'jest-preset-angular/build/serializers/html-comment', ], }; ================================================ FILE: projects/demo/package.json ================================================ { "name": "@maskito/demo", "private": true, "devDependencies": { "@angular/animations": "19.2.20", "@angular/cdk": "19.2.19", "@angular/common": "19.2.20", "@angular/core": "19.2.20", "@angular/forms": "19.2.20", "@angular/platform-browser": "19.2.20", "@angular/platform-browser-dynamic": "19.2.20", "@angular/platform-server": "19.2.20", "@angular/router": "19.2.20", "@angular/ssr": "19.2.22", "@maskito/angular": "*", "@maskito/core": "*", "@maskito/kit": "*", "@ng-web-apis/common": "4.14.0", "@ng-web-apis/universal": "4.14.0", "@stackblitz/sdk": "1.11.0", "@taiga-ui/addon-doc": "4.76.0", "@taiga-ui/addon-mobile": "4.76.0", "@taiga-ui/cdk": "4.76.0", "@taiga-ui/core": "4.76.0", "@taiga-ui/icons": "4.76.0", "@taiga-ui/kit": "4.76.0", "@taiga-ui/layout": "4.76.0", "@taiga-ui/legacy": "4.76.0", "@taiga-ui/styles": "4.76.0", "@taiga-ui/testing": "4.76.0", "browser-sync": "3.0.4", "ngx-highlightjs": "10.0.0", "react": "19.2.5", "react-dom": "19.2.5", "rxjs": "7.8.2", "tslib": "2.8.1", "vue": "3.5.32" } } ================================================ FILE: projects/demo/project.json ================================================ { "$schema": "../../node_modules/nx/schemas/project-schema.json", "name": "demo", "prefix": "app", "projectType": "application", "sourceRoot": "projects/demo/src", "targets": { "build": { "configurations": { "development": { "extractLicenses": false, "optimization": false, "outputMode": "static", "server": false, "sourceMap": true, "ssr": false }, "production": { "outputHashing": "all" }, "typecheck": { "tsConfig": "{projectRoot}/tsconfig.typecheck.json" } }, "defaultConfiguration": "production", "executor": "@nx/angular:application", "options": { "allowedCommonJsDependencies": [ "react", "react-dom/client", "react/jsx-runtime", "@vue/compiler-dom", "@vue/runtime-dom", "@vue/shared" ], "assets": [ { "glob": "**/*", "input": "{projectRoot}/src/assets/", "output": "./assets/" }, { "glob": "**/*", "input": "node_modules/@taiga-ui/icons/src", "output": "assets/taiga-ui/icons" } ], "baseHref": "/", "browser": "{projectRoot}/src/main.ts", "externalDependencies": ["react-hook-form"], "index": "{projectRoot}/src/index.html", "loader": { ".css": "text", ".html": "text", ".md": "text" }, "outputMode": "server", "outputPath": "dist/{projectName}", "plugins": ["{projectRoot}/esbuild-plugins/maskito-as-taiga-ui-dep.plugin.js", "{projectRoot}/esbuild-plugins/vue-esm.plugin.js"], "polyfills": ["zone.js"], "server": "{projectRoot}/src/main.server.ts", "ssr": { "entry": "{projectRoot}/src/server.ts" }, "styles": [ "node_modules/@taiga-ui/core/styles/taiga-ui-theme.less", "node_modules/@taiga-ui/core/styles/taiga-ui-fonts.less", "node_modules/@taiga-ui/styles/taiga-ui-global.less", "{projectRoot}/src/styles.less" ], "tsConfig": "{projectRoot}/tsconfig.app.json" }, "outputs": ["{options.outputPath}"] }, "build-gh-pages": { "executor": "nx:run-commands", "options": { "commands": [ "echo 'Github pages require special 404.html'", "echo 'Read more: https://angular.io/guide/deployment#deploy-to-github-pages'", "echo ------", "nx build {projectName} -c production", "cp dist/{projectName}/browser/index.html dist/{projectName}/browser/404.html" ], "parallel": false } }, "serve": { "configurations": { "development": { "buildTarget": "{projectName}:build:development" }, "production": { "buildTarget": "{projectName}:build:production" } }, "continuous": true, "defaultConfiguration": "development", "executor": "@nx/angular:dev-server", "options": { "port": 3333, "prebundle": { "exclude": [ "@taiga-ui/addon-doc", "@taiga-ui/addon-mobile", "@taiga-ui/cdk", "@taiga-ui/core", "@taiga-ui/icons", "@taiga-ui/kit", "@taiga-ui/layout", "@taiga-ui/legacy", "@taiga-ui/styles" ] } } }, "serve-ip": { "executor": "nx:run-commands", "options": { "command": "nx serve {projectName} --open --host 0.0.0.0 --disable-host-check" } }, "serve-ssl": { "executor": "nx:run-commands", "options": { "commands": [ "echo \"mkcert is a simple tool for making locally-trusted development certificates\"", "echo \"Read about installation and more: https://github.com/FiloSottile/mkcert\"", "echo ------", "mkcert -install", "mkdir -p .ssl", "mkcert -key-file .ssl/localhost-key.pem -cert-file .ssl/localhost.pem localhost 127.0.0.1 ::1", "nx serve --ssl" ], "parallel": false } }, "serve-static": { "continuous": true, "executor": "@nx/web:file-server", "options": { "buildTarget": "{projectName}:build", "port": 3333, "spa": true, "staticFilePath": "dist/{projectName}/browser" } }, "test": { "executor": "@nx/jest:jest", "options": { "jestConfig": "{projectRoot}/jest.config.ts", "tsConfig": "tsconfig.spec.json" }, "outputs": ["{workspaceRoot}/coverage/{projectName}"] } } } ================================================ FILE: projects/demo/src/app/app.component.html ================================================ ================================================ FILE: projects/demo/src/app/app.component.spec.ts ================================================ import {APP_BASE_HREF} from '@angular/common'; import {TestBed} from '@angular/core/testing'; import {RouterTestingHarness} from '@angular/router/testing'; import {DemoPath} from '@demo/constants'; import {beforeEach, describe, expect, it} from '@jest/globals'; import {App} from './app.component'; import {APP_CONFIG} from './app.config'; describe('Ensure unit tests work for demo application', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [App], providers: [...APP_CONFIG.providers, {provide: APP_BASE_HREF, useValue: '/'}], }); }); it('appComponent compiles properly', () => { const fixture = TestBed.createComponent(App); fixture.detectChanges(); expect(fixture.nativeElement.textContent).toContain('Getting started'); expect(fixture.nativeElement.textContent).toContain('Core concepts'); }); it('router works', async () => { const router = await RouterTestingHarness.create(); await router.navigateByUrl(DemoPath.CoreConceptsOverview); expect(router.routeNativeElement?.textContent).toContain( 'The main entity of Maskito core library is Maskito class which accepts 2 arguments in constructor', ); }); }); ================================================ FILE: projects/demo/src/app/app.component.ts ================================================ import {ViewportScroller} from '@angular/common'; import {ChangeDetectionStrategy, Component, inject} from '@angular/core'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {RouterLink} from '@angular/router'; import {DemoPath} from '@demo/constants'; import {ResizeObserverService} from '@ng-web-apis/resize-observer'; import {TUI_DOC_PAGE_LOADED, TuiDocMain} from '@taiga-ui/addon-doc'; import {tuiInjectElement} from '@taiga-ui/cdk'; import {TuiLink} from '@taiga-ui/core'; import {debounceTime, map, startWith} from 'rxjs'; @Component({ selector: 'app', imports: [RouterLink, TuiDocMain, TuiLink], templateUrl: './app.component.html', styleUrl: './app.style.less', changeDetection: ChangeDetectionStrategy.OnPush, providers: [ ResizeObserverService, { provide: TUI_DOC_PAGE_LOADED, useFactory: () => { const host = tuiInjectElement(); return inject(ResizeObserverService).pipe( startWith(null), debounceTime(0), // Synchronous scrollIntoView (after click) does not work https://stackoverflow.com/a/56971002 map(() => { const exampleElements = Array.from( host.querySelectorAll('tui-doc-example'), ); const codeElements = Array.from( host.querySelectorAll('tui-doc-code'), ); return ( exampleElements.every((el) => el.querySelector('.t-example'), ) && codeElements.every((el) => el.querySelector('.t-code')) ); }), takeUntilDestroyed(), ); }, }, ], }) export class App { protected readonly stackblitzStarterPath = `/${DemoPath.Stackblitz}`; constructor() { inject(ViewportScroller).setOffset([0, 64]); } } ================================================ FILE: projects/demo/src/app/app.config.ts ================================================ import {isPlatformBrowser, LocationStrategy, PathLocationStrategy} from '@angular/common'; import {provideHttpClient} from '@angular/common/http'; import {type ApplicationConfig, inject, PLATFORM_ID} from '@angular/core'; import {provideAnimations} from '@angular/platform-browser/animations'; import {provideRouter, withInMemoryScrolling} from '@angular/router'; import {DocExamplePrimaryTab} from '@demo/constants'; import { TUI_DOC_CODE_EDITOR, TUI_DOC_DEFAULT_TABS, TUI_DOC_EXAMPLE_CONTENT_PROCESSOR, TUI_DOC_LOGO, TUI_DOC_PAGES, TUI_DOC_SOURCE_CODE, TUI_DOC_TITLE, TUI_DOC_TYPE_REFERENCE_HANDLER, tuiDocExampleOptionsProvider, type TuiDocSourceCodePathOptions, } from '@taiga-ui/addon-doc'; import {NG_EVENT_PLUGINS} from '@taiga-ui/event-plugins'; import type {PolymorpheusContent} from '@taiga-ui/polymorpheus'; import {HIGHLIGHT_OPTIONS} from 'ngx-highlightjs'; import {DEMO_PAGES} from '../pages/pages'; import {StackblitzService} from '../pages/stackblitz'; import {ROUTES} from './app.routes'; import { ANGULAR_LOGO, JAVASCRIPT_LOGO, REACT_LOGO, } from './modules/example-primary-tabs-icons'; import {VUE_LOGO} from './modules/example-primary-tabs-icons/vue-logo.component'; import {LOGO_CONTENT} from './modules/logo/logo.component'; import {addDefaultTabsProcessor} from './utils'; export const APP_CONFIG: ApplicationConfig = { providers: [ provideAnimations(), provideRouter( ROUTES, withInMemoryScrolling({ scrollPositionRestoration: 'enabled', anchorScrolling: 'enabled', }), ), NG_EVENT_PLUGINS, provideHttpClient(), { provide: LocationStrategy, useClass: PathLocationStrategy, }, { provide: TUI_DOC_TITLE, useValue: 'Maskito | ', }, { provide: TUI_DOC_LOGO, useValue: LOGO_CONTENT, }, { provide: TUI_DOC_DEFAULT_TABS, useValue: ['Description and examples', 'API'], }, { provide: TUI_DOC_PAGES, useValue: DEMO_PAGES, }, { provide: TUI_DOC_SOURCE_CODE, useValue: (context: TuiDocSourceCodePathOptions) => { const link = 'https://github.com/taiga-family/maskito/tree/main/projects'; if (context.path) { return `${link}/${context.path}`; } if (context.package.toLowerCase() !== 'kit') { return null; } return `${link}/${context.package.toLowerCase()}/src/lib/masks/${`${context.header.slice(0, 1).toLowerCase()}${context.header.slice(1)}`.replaceAll( /[A-Z]/g, (m) => `-${m.toLowerCase()}`, )}`; }, }, { provide: TUI_DOC_CODE_EDITOR, useClass: StackblitzService, }, { provide: TUI_DOC_EXAMPLE_CONTENT_PROCESSOR, useValue: addDefaultTabsProcessor, }, tuiDocExampleOptionsProvider({ codeEditorVisibilityHandler: (files) => { const fileNames = Object.keys(files); return ( fileNames.includes(DocExamplePrimaryTab.MaskitoOptions) && fileNames.includes(DocExamplePrimaryTab.JavaScript) ); }, tabTitles: new Map([ [DocExamplePrimaryTab.Angular, ANGULAR_LOGO], [DocExamplePrimaryTab.JavaScript, JAVASCRIPT_LOGO], [DocExamplePrimaryTab.React, REACT_LOGO], [DocExamplePrimaryTab.Vue, VUE_LOGO], ]), }), { provide: HIGHLIGHT_OPTIONS, useFactory: () => { const isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); return { coreLibraryLoader: async () => import('highlight.js/lib/core'), lineNumbersLoader: async () => // SSR ReferenceError: window is not defined isBrowser ? import('ngx-highlightjs/line-numbers') : Promise.resolve(), languages: { typescript: async () => import('highlight.js/lib/languages/typescript'), less: async () => import('highlight.js/lib/languages/less'), xml: async () => import('highlight.js/lib/languages/xml'), }, }; }, }, { provide: TUI_DOC_TYPE_REFERENCE_HANDLER, useValue: (type: string) => { if (type.toLowerCase().startsWith('maskito')) { return `https://github.com/search?q=%2F%28enum%7Ctype%7Cinterface%7Cclass%7Cfunction%7Cconst%29+${type}%28%3C%7C%5Cs%29%2F+language%3ATypeScript+repo%3Ataiga-family%2Fmaskito+&type=code`; } switch (type) { case 'Date': return 'https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Date'; case 'MetadataJson': return 'https://github.com/catamphetamine/libphonenumber-js?tab=readme-ov-file#min-vs-max-vs-mobile-vs-core'; default: return null; } }, }, ], }; ================================================ FILE: projects/demo/src/app/app.routes.ts ================================================ import type {Routes} from '@angular/router'; import {DemoPath} from '@demo/constants'; import {tuiProvideRoutePageTab} from '@taiga-ui/addon-doc'; /* eslint-disable @typescript-eslint/promise-function-async */ export const ROUTES: Routes = [ // Getting started { path: '', loadComponent: () => import('../pages/documentation/what-is-maskito/what-is-maskito.component'), title: 'What is Maskito?', }, { path: DemoPath.WhatIsMaskito, loadComponent: () => import('../pages/documentation/what-is-maskito/what-is-maskito.component'), title: 'What is Maskito?', }, { path: DemoPath.MaskitoLibraries, loadComponent: () => import('../pages/documentation/maskito-libraries/maskito-libraries.component'), title: 'Maskito libraries', }, // Core concepts { path: DemoPath.CoreConceptsOverview, loadComponent: () => import('../pages/documentation/core-concepts-overview/core-concepts-overview.component'), title: 'Core concepts', }, { path: DemoPath.MaskExpression, loadComponent: () => import('../pages/documentation/mask-expression/mask-expression.component'), title: 'Mask expression', }, { path: DemoPath.ElementState, loadComponent: () => import('../pages/documentation/element-state/element-state.component'), title: 'Element state', }, { path: DemoPath.Processors, loadComponent: () => import('../pages/documentation/processors/processors.component'), title: 'Processors', }, { path: DemoPath.Plugins, loadComponent: () => import('../pages/documentation/plugins/plugins.component'), title: 'Plugins', }, { path: DemoPath.OverwriteMode, loadComponent: () => import('../pages/documentation/overwrite-mode/overwrite-mode.component'), title: 'Overwrite mode', }, { path: DemoPath.Transformer, loadComponent: () => import('../pages/documentation/transformer/transformer.component'), title: 'Transformer', }, // Frameworks { path: DemoPath.Angular, loadComponent: () => import('../pages/frameworks/angular/angular-doc.component'), title: 'Angular', }, { path: DemoPath.React, loadComponent: () => import('../pages/frameworks/react/react-doc.component'), title: 'React', }, { path: DemoPath.Vue, loadComponent: () => import('../pages/frameworks/vue/vue-doc.component'), title: 'Vue', }, // Kit { path: DemoPath.Number, loadComponent: () => import('../pages/kit/number/number-mask-doc.component'), title: 'Number', }, { path: DemoPath.Time, loadComponent: () => import('../pages/kit/time/time-mask-doc.component'), title: 'Time', }, { path: DemoPath.Date, loadComponent: () => import('../pages/kit/date/date-mask-doc.component'), title: 'Date', }, { path: DemoPath.DateRange, loadComponent: () => import('../pages/kit/date-range/date-range-mask-doc.component'), title: 'DateRange', }, { path: DemoPath.KitPlugins, loadComponent: () => import('../pages/kit/plugins/kit-plugins-doc.component'), title: 'Plugins | @maskito/kit', }, // Recipes { path: DemoPath.DateTime, loadComponent: () => import('../pages/kit/date-time/date-time-mask-doc.component'), title: 'DateTime', }, { path: DemoPath.Card, loadComponent: () => import('../pages/recipes/card/card-doc.component'), title: 'Card', }, { path: DemoPath.Phone, loadComponent: () => import('../pages/recipes/phone/phone-doc.component'), title: 'Phone', }, { path: DemoPath.PhonePackage, loadComponent: () => import('../pages/phone/phone-doc.component'), title: 'Phone', }, { path: DemoPath.Textarea, loadComponent: () => import('../pages/recipes/textarea/textarea-doc.component'), title: 'Textarea', }, { path: DemoPath.ContentEditable, loadComponent: () => import('../pages/recipes/content-editable/content-editable-doc.component'), title: 'ContentEditable', }, { path: DemoPath.Prefix, loadComponent: () => import('../pages/recipes/prefix/prefix-doc.component'), title: 'With prefix', }, { path: DemoPath.Postfix, loadComponent: () => import('../pages/recipes/postfix/postfix-doc.component'), title: 'With postfix', }, { path: DemoPath.Placeholder, loadComponent: () => import('../pages/recipes/placeholder/placeholder-doc.component'), title: 'With placeholder', }, { path: DemoPath.NetworkAddress, loadComponent: () => import('../pages/recipes/network-address/network-address-doc.component'), title: 'Network address', }, // Other { path: DemoPath.BrowserSupport, loadComponent: () => import('../pages/documentation/browser-support/browser-support.component'), title: 'Browser support', }, { path: DemoPath.SupportedInputTypes, loadComponent: () => import('../pages/documentation/supported-input-types/supported-input-types.component'), title: 'Supported types', }, { path: DemoPath.RealWorldForm, loadComponent: () => import('../pages/documentation/real-world-form'), title: 'Maskito in Real World Form', }, { path: DemoPath.Stackblitz, loadComponent: () => import('../pages/stackblitz').then((m) => m.StackblitzStarterComponent), title: 'Stackblitz Starter', }, ] .map(tuiProvideRoutePageTab) .concat({ path: '**', redirectTo: DemoPath.WhatIsMaskito, }); ================================================ FILE: projects/demo/src/app/app.style.less ================================================ @import '@taiga-ui/core/styles/taiga-ui-local.less'; :host { display: block; font: var(--tui-font-text-m); color: var(--tui-text-primary); } .link { margin-inline-start: 1rem; } ================================================ FILE: projects/demo/src/app/constants/demo-path.ts ================================================ export const DemoPath = { WhatIsMaskito: 'getting-started/what-is-maskito', MaskitoLibraries: 'getting-started/maskito-libraries', CoreConceptsOverview: 'core-concepts/overview', MaskExpression: 'core-concepts/mask-expression', ElementState: 'core-concepts/element-state', Processors: 'core-concepts/processors', Plugins: 'core-concepts/plugins', OverwriteMode: 'core-concepts/overwrite-mode', Transformer: 'core-concepts/transformer', Angular: 'frameworks/angular', React: 'frameworks/react', Vue: 'frameworks/vue', Number: 'kit/number', Time: 'kit/time', Date: 'kit/date', DateRange: 'kit/date-range', DateTime: 'kit/date-time', KitPlugins: 'kit/plugins', PhonePackage: 'addons/phone', Card: 'recipes/card', Phone: 'recipes/phone', Textarea: 'recipes/textarea', ContentEditable: 'recipes/content-editable', Prefix: 'recipes/prefix', Postfix: 'recipes/postfix', Placeholder: 'recipes/placeholder', NetworkAddress: 'recipes/network-address', BrowserSupport: 'browser-support', SupportedInputTypes: 'supported-input-types', RealWorldForm: 'real-world-form', Stackblitz: 'stackblitz', } as const; ================================================ FILE: projects/demo/src/app/constants/doc-example-primary-tab.ts ================================================ export const DocExamplePrimaryTab = { MaskitoOptions: 'mask', JavaScript: 'JavaScript', Angular: 'Angular', React: 'React', Vue: 'Vue', } as const; ================================================ FILE: projects/demo/src/app/constants/index.ts ================================================ export * from './demo-path'; export * from './doc-example-primary-tab'; ================================================ FILE: projects/demo/src/app/modules/example-primary-tabs-icons/angular-logo.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {PolymorpheusComponent} from '@taiga-ui/polymorpheus'; @Component({ selector: 'angular-logo', template: '', styles: ['img {display: flex; width: 1.5rem}'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class AngularLogoComponent {} export const ANGULAR_LOGO = new PolymorpheusComponent(AngularLogoComponent); ================================================ FILE: projects/demo/src/app/modules/example-primary-tabs-icons/index.ts ================================================ export * from './angular-logo.component'; export * from './javascript-logo.component'; export * from './react-logo.component'; ================================================ FILE: projects/demo/src/app/modules/example-primary-tabs-icons/javascript-logo.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {PolymorpheusComponent} from '@taiga-ui/polymorpheus'; @Component({ selector: 'javascript-logo', template: '', styles: ['img {display: flex; width: 1.5rem}'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class JavaScriptLogoComponent {} export const JAVASCRIPT_LOGO = new PolymorpheusComponent(JavaScriptLogoComponent); ================================================ FILE: projects/demo/src/app/modules/example-primary-tabs-icons/react-logo.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {PolymorpheusComponent} from '@taiga-ui/polymorpheus'; @Component({ selector: 'react-logo', template: '', styles: ['img {display: flex; width: 1.5rem}'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ReactLogoComponent {} export const REACT_LOGO = new PolymorpheusComponent(ReactLogoComponent); ================================================ FILE: projects/demo/src/app/modules/example-primary-tabs-icons/vue-logo.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {PolymorpheusComponent} from '@taiga-ui/polymorpheus'; @Component({ selector: 'vue-logo', template: '', styles: ['img {display: flex; width: 1.5rem}'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class VueLogoComponent {} export const VUE_LOGO = new PolymorpheusComponent(VueLogoComponent); ================================================ FILE: projects/demo/src/app/modules/logo/logo.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {RouterLink} from '@angular/router'; import {TuiLink} from '@taiga-ui/core'; import {PolymorpheusComponent} from '@taiga-ui/polymorpheus'; @Component({ selector: 'logo', imports: [RouterLink, TuiLink], templateUrl: './logo.template.html', styleUrl: './logo.style.less', changeDetection: ChangeDetectionStrategy.OnPush, }) export class LogoComponent {} export const LOGO_CONTENT = new PolymorpheusComponent(LogoComponent); ================================================ FILE: projects/demo/src/app/modules/logo/logo.style.less ================================================ @import '@taiga-ui/core/styles/taiga-ui-local.less'; :host { display: flex; align-items: center; @media @tui-mobile { font-size: 0; } } .logo-link { display: flex; } .logo-name { display: flex; color: var(--tui-text-primary); } .logo { margin-inline-end: 0.625rem; } .by { margin-inline-start: 0.875rem; @media @tui-mobile { display: none; } } ================================================ FILE: projects/demo/src/app/modules/logo/logo.template.html ================================================ Maskito ================================================ FILE: projects/demo/src/app/server-error-handler.ts ================================================ import {type ErrorHandler, Injectable} from '@angular/core'; // TODO const KNOWN_ISSUES: ReadonlyArray = [ /** * ``` * // mask.ts * export default {mask: '...'} * * // another-file.ts * import('./mask.ts', {with: {loader: 'text'}}) * .then(x => x.default) * ``` * During SERVER side rendering, `x.default` invalidly equals to `{mask: '...'}` object. * During CLIENT side rendering, `x.default` correctly equals to raw file content. * * TODO(v6): no more relevant for Angular >= 20 */ 'Input data should be a String', // Same here /Cannot find (module|package) 'react-hook-form' imported from/, // TODO(v6): remove after Angular bump to >= 20 ]; @Injectable() export class ServerErrorHandler implements ErrorHandler { public handleError(error: Error | string): void { const errorMessage = (typeof error === 'string' ? error : error.message) || ''; if ( KNOWN_ISSUES.some((issue) => typeof issue === 'string' ? errorMessage.includes(issue) : errorMessage.match(issue), ) ) { return; } console.error(errorMessage); if ( // Default environment variables for GitHub CI process.env.CI // https://docs.github.com/en/actions/reference/workflows-and-actions/variables#default-environment-variables ) { process.exit(1); } } } ================================================ FILE: projects/demo/src/app/utils/add-default-tabs-processor/add-default-tabs-processor.ts ================================================ import {DocExamplePrimaryTab} from '@demo/constants'; import type {TuiHandler} from '@taiga-ui/cdk'; import {ANGULAR_DEFAULT_TAB} from './default-tabs/angular-default-tab'; import {JS_DEFAULT_TAB} from './default-tabs/js-default-tab'; import {REACT_DEFAULT_TAB} from './default-tabs/react-default-tab'; import {VUE_DEFAULT_TAB} from './default-tabs/vue-default-tab'; export const addDefaultTabsProcessor: TuiHandler< Record, Record > = (files) => { const fileNames = Object.keys(files); return fileNames.length === 1 && fileNames[0] === DocExamplePrimaryTab.MaskitoOptions ? { ...files, [DocExamplePrimaryTab.JavaScript]: JS_DEFAULT_TAB, [DocExamplePrimaryTab.Angular]: ANGULAR_DEFAULT_TAB, [DocExamplePrimaryTab.React]: REACT_DEFAULT_TAB, [DocExamplePrimaryTab.Vue]: VUE_DEFAULT_TAB, } : files; }; ================================================ FILE: projects/demo/src/app/utils/add-default-tabs-processor/default-tabs/angular-default-tab.ts ================================================ export const ANGULAR_DEFAULT_TAB = `import {Component} from '@angular/core'; import {MaskitoDirective} from '@maskito/angular'; import type {MaskitoOptions} from '@maskito/core'; import mask from './mask'; @Component({ selector: 'my-app', imports: [MaskitoDirective], template: '', }) export class App { readonly options: MaskitoOptions = mask; }`; ================================================ FILE: projects/demo/src/app/utils/add-default-tabs-processor/default-tabs/js-default-tab.ts ================================================ export const JS_DEFAULT_TAB = `import {Maskito, MaskitoOptions} from '@maskito/core'; import maskitoOptions from './mask'; const element = document.querySelector('input,textarea')!; const maskedInput = new Maskito(element, maskitoOptions); // Call this function when the element is detached from DOM maskedInput.destroy();`; ================================================ FILE: projects/demo/src/app/utils/add-default-tabs-processor/default-tabs/react-default-tab.ts ================================================ export const REACT_DEFAULT_TAB = `import * as React from 'react'; import {useMaskito} from '@maskito/react'; import options from './mask'; export default function App() { const maskedInputRef = useMaskito({options}); return ; }`; ================================================ FILE: projects/demo/src/app/utils/add-default-tabs-processor/default-tabs/vue-default-tab.ts ================================================ export const VUE_DEFAULT_TAB = `import {createApp} from 'vue'; import {maskito} from '@maskito/vue'; import options from './mask'; const app = createApp({ template: '', directives: {maskito}, data: () => ({ options }), });`; ================================================ FILE: projects/demo/src/app/utils/index.ts ================================================ export * from './add-default-tabs-processor/add-default-tabs-processor'; ================================================ FILE: projects/demo/src/assets/manifest.webmanifest ================================================ { "icons": [ {"src": "/favicon-192.png", "type": "image/png", "sizes": "192x192"}, {"src": "/favicon-512.png", "type": "image/png", "sizes": "512x512"} ] } ================================================ FILE: projects/demo/src/environments/environment.prod.ts ================================================ export const environment = {production: true}; ================================================ FILE: projects/demo/src/environments/environment.ts ================================================ export const environment = {production: false}; ================================================ FILE: projects/demo/src/index.html ================================================ Maskito Loading... ================================================ FILE: projects/demo/src/main.server.ts ================================================ import {type ApplicationRef, ErrorHandler, mergeApplicationConfig} from '@angular/core'; import {bootstrapApplication, type BootstrapContext} from '@angular/platform-browser'; import {provideServerRendering} from '@angular/platform-server'; import {provideServerRouting, RenderMode, type ServerRoute} from '@angular/ssr'; import {DemoPath} from '@demo/constants'; import {UNIVERSAL_PROVIDERS} from '@ng-web-apis/universal'; import {App} from './app/app.component'; import {APP_CONFIG} from './app/app.config'; import {ROUTES} from './app/app.routes'; import {ServerErrorHandler} from './app/server-error-handler'; /* eslint-disable @typescript-eslint/require-await */ const serverConfig = mergeApplicationConfig(APP_CONFIG, { providers: [ provideServerRendering(), provideServerRouting( ROUTES.map((route) => { const path = route.path ?? ''; switch (path) { case DemoPath.Angular: return withTabs(path, ['Setup']); case DemoPath.Number: return withTabs(path, ['API', 'Helpers']); case DemoPath.Plugins: return withTabs(path, ['Built-in_core_plugins']); default: return path.startsWith('kit') || path.startsWith('addons') ? withTabs(path, ['API']) : { path, renderMode: RenderMode.Prerender, async getPrerenderParams() { return []; }, }; } }), ), UNIVERSAL_PROVIDERS, {provide: ErrorHandler, useClass: ServerErrorHandler}, ], }); function withTabs(path: string, tabs: string[]): ServerRoute { return { path: `${path}/:tab`, renderMode: RenderMode.Prerender, async getPrerenderParams() { return tabs.map((tab) => ({tab})); }, }; } export default async (context: BootstrapContext): Promise => bootstrapApplication(App, serverConfig, context); ================================================ FILE: projects/demo/src/main.ts ================================================ import {bootstrapApplication} from '@angular/platform-browser'; import {App} from './app/app.component'; import {APP_CONFIG} from './app/app.config'; bootstrapApplication(App, APP_CONFIG).catch((err: unknown) => console.error(err)); ================================================ FILE: projects/demo/src/pages/documentation/browser-support/browser-support.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {TuiAddonDoc} from '@taiga-ui/addon-doc'; @Component({ selector: 'browser-support', imports: [TuiAddonDoc], templateUrl: './browser-support.template.html', styles: ['td {width: 18.75rem}'], changeDetection: ChangeDetectionStrategy.OnPush, }) export default class BrowserSupportComponent { protected readonly desktopBrowsers = [ {name: 'Google Chrome', version: '88+'}, {name: 'Mozilla Firefox', version: '120+'}, {name: 'Safari', version: '14.1+'}, {name: 'Opera', version: '74+'}, {name: 'Edge', version: '88+'}, {name: 'Yandex Browser', version: '21.2+'}, {name: 'Microsoft Internet Explorer', version: null}, ] as const; protected readonly mobileBrowsers = [ {name: 'Google Chrome', version: '88+'}, {name: 'Mozilla Firefox', version: '120+'}, {name: 'Safari', version: '14.5+'}, {name: 'Opera', version: '63+'}, {name: 'Samsung Mobile', version: '15+'}, {name: 'Yandex Browser', version: '21.2+'}, ]; } ================================================ FILE: projects/demo/src/pages/documentation/browser-support/browser-support.template.html ================================================

Desktop

@for (browser of desktopBrowsers; track browser) { }
Browser Version
{{ browser.name }} @if (browser.version) { {{ browser.version }} } @else { Not supported }

Mobile

@for (browser of mobileBrowsers; track browser) { }
Browser Version
{{ browser.name }} {{ browser.version }}
================================================ FILE: projects/demo/src/pages/documentation/core-concepts-overview/core-concepts-overview.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {RouterLink} from '@angular/router'; import {DemoPath} from '@demo/constants'; import {TuiAddonDoc} from '@taiga-ui/addon-doc'; import {TuiIcon, TuiLink, TuiNotification, TuiSurface, TuiTitle} from '@taiga-ui/core'; import {TuiTooltip} from '@taiga-ui/kit'; import {TuiCardLarge, TuiHeader} from '@taiga-ui/layout'; @Component({ selector: 'core-concepts-overview-doc-page', imports: [ RouterLink, TuiAddonDoc, TuiCardLarge, TuiHeader, TuiIcon, TuiLink, TuiNotification, TuiSurface, TuiTitle, TuiTooltip, ], templateUrl: './core-concepts-overview.template.html', styleUrl: './core-concepts-overview.styles.less', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class CoreConceptsOverviewDocPageComponent { protected readonly maskitoPublicApiDemo = import('./examples/maskito-public-api-demo.md'); protected readonly maskExpressionDocPage = `/${DemoPath.MaskExpression}`; protected readonly processorsDocPage = `/${DemoPath.Processors}`; protected readonly pluginsDocPage = `/${DemoPath.Plugins}`; protected readonly overwriteModeDocPage = `/${DemoPath.OverwriteMode}`; protected readonly transformerDocPage = `/${DemoPath.Transformer}`; protected readonly supportedInputTypesDocPage = `/${DemoPath.SupportedInputTypes}`; } ================================================ FILE: projects/demo/src/pages/documentation/core-concepts-overview/core-concepts-overview.styles.less ================================================ @import '@taiga-ui/core/styles/taiga-ui-local.less'; .cards { display: flex; flex-wrap: wrap; justify-content: center; gap: 2rem; @media @tui-mobile { flex-direction: column; } & [tuiCardLarge] { flex: 1; min-inline-size: 14rem; @media @tui-desktop-min { max-inline-size: 40%; } } } ================================================ FILE: projects/demo/src/pages/documentation/core-concepts-overview/core-concepts-overview.template.html ================================================

The main entity of Maskito core library is Maskito class which accepts 2 arguments in constructor:

  1. native HTMLInputElement Maskito supports only limited types of HTMLInputElement due to some browser limitations!

    See a full list of supported types

    or HTMLTextAreaElement
  2. set of configurable MaskitoOptions
Avoid wasting computation power or memory resources!

The only available public method destroy removes all created event listeners. Call it to clean everything up when the work is finished.

To understand the capabilities of the Maskito library, you need to learn about the following features and concepts:

================================================ FILE: projects/demo/src/pages/documentation/core-concepts-overview/examples/maskito-public-api-demo.md ================================================ ```ts import {Maskito, maskitoInitialCalibrationPlugin} from '@maskito/core'; const maskedInput = new Maskito(element, { mask: /^\d+$/, preprocessors: [preprocessor1, preprocessor2], postprocessors: [ ({value, selection}) => { // ... }, ], plugins: [myCustomPlugin, maskitoInitialCalibrationPlugin()], overwriteMode: 'shift', }); // Call it when the element is destroyed maskedInput.destroy(); ``` ================================================ FILE: projects/demo/src/pages/documentation/element-state/element-state.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {RouterLink} from '@angular/router'; import {DemoPath} from '@demo/constants'; import {TuiAddonDoc} from '@taiga-ui/addon-doc'; import {TuiLink} from '@taiga-ui/core'; @Component({ selector: 'element-state-doc-page', imports: [RouterLink, TuiAddonDoc, TuiLink], templateUrl: './element-state.template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class ElementStateDocPageComponent { protected readonly elementStateDemo = import('./examples/element-state-demo.md'); protected readonly maskExpressionDocPage = `/${DemoPath.MaskExpression}`; protected readonly processorsDocPage = `/${DemoPath.Processors}`; protected readonly overwriteModeDocPage = `/${DemoPath.OverwriteMode}`; } ================================================ FILE: projects/demo/src/pages/documentation/element-state/element-state.template.html ================================================

Element state is a concept which describes the main properties of the masked element at the certain period of time.

It is an object which implements the following interface:

This concept is actively used throughout Maskito libraries, and you can find its usage in the following topics:

================================================ FILE: projects/demo/src/pages/documentation/element-state/examples/element-state-demo.md ================================================ ```ts interface ElementState { // the value of a masked or `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class OverwriteModeDocExample3 { protected maskitoOptions = mask; protected initialValue = 'This artificial example demonstrates the usage of dynamic mode. If this textarea contains only digits — "replace" mode is enabled. Otherwise, "shift" mode is enabled.'; } ================================================ FILE: projects/demo/src/pages/documentation/overwrite-mode/examples/dynamic/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; const maskitoOptions: MaskitoOptions = { mask: /^[^а-яё]+$/i, overwriteMode: ({value}) => { const includesOnlyDigits = /^\d+$/.test(value); return includesOnlyDigits ? 'replace' : 'shift'; }, }; export default maskitoOptions; ================================================ FILE: projects/demo/src/pages/documentation/overwrite-mode/examples/replace/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiHint} from '@taiga-ui/core'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'overwrite-mode-replace-doc-example-2', imports: [ FormsModule, MaskitoDirective, TuiHint, TuiInputModule, TuiTextfieldControllerModule, ], template: ` `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class OverwriteModeDocExample2 { protected readonly maskitoOptions = mask; protected value = '0000'; } ================================================ FILE: projects/demo/src/pages/documentation/overwrite-mode/examples/replace/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; const maskitoOptions: MaskitoOptions = { mask: /^\d+$/, overwriteMode: 'replace', }; export default maskitoOptions; ================================================ FILE: projects/demo/src/pages/documentation/overwrite-mode/examples/shift/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiHint} from '@taiga-ui/core'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'overwrite-mode-shift-doc-example-1', imports: [ FormsModule, MaskitoDirective, TuiHint, TuiInputModule, TuiTextfieldControllerModule, ], template: ` `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class OverwriteModeDocExample1 { protected readonly maskitoOptions = mask; protected value = '0000'; } ================================================ FILE: projects/demo/src/pages/documentation/overwrite-mode/examples/shift/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; const maskitoOptions: MaskitoOptions = { mask: /^\d+$/, overwriteMode: 'shift', }; export default maskitoOptions; ================================================ FILE: projects/demo/src/pages/documentation/overwrite-mode/overwrite-mode.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {RouterLink} from '@angular/router'; import {DemoPath, DocExamplePrimaryTab} from '@demo/constants'; import {TuiAddonDoc, type TuiRawLoaderContent} from '@taiga-ui/addon-doc'; import {TuiLink} from '@taiga-ui/core'; import {NextStepsComponent} from '../next-steps/next-steps.component'; import {OverwriteModeDocExample3} from './examples/dynamic/component'; import {OverwriteModeDocExample2} from './examples/replace/component'; import {OverwriteModeDocExample1} from './examples/shift/component'; @Component({ selector: 'overwrite-mode-doc-page', imports: [ NextStepsComponent, OverwriteModeDocExample1, OverwriteModeDocExample2, OverwriteModeDocExample3, RouterLink, TuiAddonDoc, TuiLink, ], templateUrl: './overwrite-mode.template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class OverwriteModeDocPageComponent { protected readonly shiftExample: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/shift/mask.ts?raw', { with: {loader: 'text'}, }), }; protected readonly replaceExample: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/replace/mask.ts?raw', { with: {loader: 'text'}, }), }; protected readonly dynamicExample: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/dynamic/mask.ts?raw', { with: {loader: 'text'}, }), }; protected readonly elementStateDocPage = `/${DemoPath.ElementState}`; } ================================================ FILE: projects/demo/src/pages/documentation/overwrite-mode/overwrite-mode.template.html ================================================

Overwrite mode regulates behaviour of the mask when user inserts a new character somewhere in the middle of text field, overwriting the character at the current index.

overwriteMode can be of a following type:

  • shift (default)
  • replace
  • function that receives element state as an argument and returns shift or replace
The classic mode that everyone is used to. Inserting a new character in the middle of the text field value shifts all following characters to the right. All new inserted characters replace the old characters at the same position. No character shifts. The length of the value remains the same after inserting new character somewhere in middle of the text field. Parameter overwriteMode also accepts function that will called before each insertion of new characters. This function has one argument — current element state (read more about it in the "Element state" section). And this function should return one of two possible values: shift or replace .
================================================ FILE: projects/demo/src/pages/documentation/plugins/examples/1-initial-calibration/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'plugins-initial-calibration-doc-example-2', imports: [FormsModule, MaskitoDirective, TuiInputModule], template: ` Enter number `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class PluginsDocExample2 { protected maskitoOptions = mask; protected value = '12345'; } ================================================ FILE: projects/demo/src/pages/documentation/plugins/examples/1-initial-calibration/index.md ================================================ ```ts import {Maskito} from '@maskito/core'; import maskitoOptions from './mask'; const element = document.querySelector('input')!; element.value = '12345'; // patch with invalid initial value // enable mask const maskedInput = new Maskito(element, maskitoOptions); console.info(element.value); // 123 // Call this function when the element is detached from DOM maskedInput.destroy(); ``` ================================================ FILE: projects/demo/src/pages/documentation/plugins/examples/1-initial-calibration/mask.ts ================================================ import {maskitoInitialCalibrationPlugin, type MaskitoOptions} from '@maskito/core'; const maskitoOptions: MaskitoOptions = { mask: /^\d{0,3}$/, plugins: [maskitoInitialCalibrationPlugin()], }; export default maskitoOptions; ================================================ FILE: projects/demo/src/pages/documentation/plugins/examples/2-strict-composition/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'plugins-strict-composition-doc-example-3', imports: [FormsModule, MaskitoDirective, TuiInputModule], template: ` Enter number `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class PluginsDocExample3 { protected readonly maskitoOptions = mask; protected value = ''; } ================================================ FILE: projects/demo/src/pages/documentation/plugins/examples/2-strict-composition/mask.ts ================================================ import {type MaskitoOptions, maskitoStrictCompositionPlugin} from '@maskito/core'; export default { mask: /^[0-90-9]*$/, plugins: [maskitoStrictCompositionPlugin()], } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/documentation/plugins/examples/3-change-event/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'plugins-change-event-doc-example-4', imports: [FormsModule, MaskitoDirective, TuiInputModule], template: ` Enter number `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class PluginsDocExample4 { protected readonly maskitoOptions = mask; protected value = ''; protected log(anything: any): void { console.info(anything); } } ================================================ FILE: projects/demo/src/pages/documentation/plugins/examples/3-change-event/mask.ts ================================================ import {maskitoChangeEventPlugin, type MaskitoOptions} from '@maskito/core'; import {maskitoNumberOptionsGenerator} from '@maskito/kit'; const numberOptions = maskitoNumberOptionsGenerator({maximumFractionDigits: 2}); export default { ...numberOptions, plugins: [ ...numberOptions.plugins, maskitoChangeEventPlugin(), // <--- Enable it ], } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/documentation/plugins/examples/oversimplified-number-mask.md ================================================ ```ts import {MaskitoOptions} from '@maskito/core'; export default { /** * ^ – beginning of the string * \d – any digit * \d* – any number of digits * \.? – optional point to start decimal part * $ – ending of the string */ mask: /^\d*\.?\d*$/, } satisfies MaskitoOptions; ``` ================================================ FILE: projects/demo/src/pages/documentation/plugins/examples/pads-zero-plugin.ts ================================================ import { /** * HTMLElement + some common properties from: * - HTMLInputElement * - HTMLTextAreaElement * - [contenteditable] */ type MaskitoElement, type MaskitoOptions, maskitoUpdateElement, } from '@maskito/core'; export default { mask: /^\d*(?:\.\d*)?$/, plugins: [ (element: MaskitoElement, _: MaskitoOptions) => { const blurHandler = (): void => { if (element.value.startsWith('.')) { /** * ❌ Anti-Pattern: * ``` * element.value = `0${element.value}`; * ``` */ maskitoUpdateElement(element, `0${element.value}`); } }; element.addEventListener('blur', blurHandler); // register a clean-up callback that is invoked when the mask is destroyed return () => element.removeEventListener('blur', blurHandler); }, ], } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/documentation/plugins/plugins.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {RouterLink} from '@angular/router'; import {DemoPath, DocExamplePrimaryTab} from '@demo/constants'; import {MaskitoDirective} from '@maskito/angular'; import {TuiAddonDoc, type TuiRawLoaderContent} from '@taiga-ui/addon-doc'; import {TuiLink, TuiSurface, TuiTextfield, TuiTitle} from '@taiga-ui/core'; import {TuiCardLarge, TuiHeader} from '@taiga-ui/layout'; import {NextStepsComponent} from '../next-steps/next-steps.component'; import {PluginsDocExample2} from './examples/1-initial-calibration/component'; import {PluginsDocExample3} from './examples/2-strict-composition/component'; import {PluginsDocExample4} from './examples/3-change-event/component'; import documentationMask from './examples/pads-zero-plugin'; @Component({ selector: 'plugins-mode-doc-page', imports: [ FormsModule, MaskitoDirective, NextStepsComponent, PluginsDocExample2, PluginsDocExample3, PluginsDocExample4, RouterLink, TuiAddonDoc, TuiCardLarge, TuiHeader, TuiLink, TuiSurface, TuiTextfield, TuiTitle, ], templateUrl: './plugins.template.html', styleUrl: './plugins.style.less', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class PluginsDocPageComponent { protected readonly transformerDocPage = `/${DemoPath.Transformer}`; protected readonly maskExpressionDocPage = `/${DemoPath.MaskExpression}`; protected readonly kitPluginsDocPage = `/${DemoPath.KitPlugins}`; protected readonly documentationMask = documentationMask; protected readonly oversimplifiedNumberMask = import('./examples/oversimplified-number-mask.md'); protected readonly padsZeroPlugin = import('./examples/pads-zero-plugin.ts?raw', { with: {loader: 'text'}, }); protected readonly initialCalibrationExample: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/1-initial-calibration/mask.ts?raw', {with: {loader: 'text'}} ), 'index.ts': import('./examples/1-initial-calibration/index.md'), }; protected readonly strictCompositionExample: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/2-strict-composition/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly changeEventExample: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/3-change-event/mask.ts?raw', {with: {loader: 'text'}} ), }; } ================================================ FILE: projects/demo/src/pages/documentation/plugins/plugins.style.less ================================================ @import '@taiga-ui/core/styles/taiga-ui-local.less'; .cards { display: flex; flex-wrap: wrap; gap: 1rem; & [tuiCardLarge] { flex: 1; min-inline-size: 20rem; @media @tui-desktop-min { max-inline-size: 45%; } } } ================================================ FILE: projects/demo/src/pages/documentation/plugins/plugins.template.html ================================================

Plugins are functions that are called with input/textarea element and mask options as arguments upon mask initialization. They can optionally return cleanup logic and allow you to extend mask with arbitrary additional behavior.

Create Your Own Plugin

Let's explore this concept by solving an oversimplified task.

Imagine that you've created a mask to allow users entering only number with decimal part. If you explored documentation section "Mask expression" , it is piece of cake for you – everything is achieved by a base knowledge of RegExp and a few code lines:

It works fine, users and your boss are almost completely satisfied. The last desired detail - no empty integer part on blur.

For example, imagine that you have the such textfield:

User sets caret before point => press Backspace => blur textfield. The result is following:

Looks not perfect, right? You need to "send signal" for users that the such incomplete value will be interpreted as 0.23 in your system. One way to do it – pads leading point with zero on blur event. Let's use the plugin for achieve it!

Good job! Now, you are ready to create your own plugin. Explore the result in action (the created mask is applied to the textfield below):

Explore built-in plugins

Maskito libraries were created to prevent only user from typing invalid value. However, sometimes you (developer) need to enable mask but you not sure that you programmatically patched textfield with valid value. In this case you can use maskitoTransform or just add maskitoInitialCalibrationPlugin to mask options.

By default, Maskito does not break IME Composition and waits until compositionend fires to begin calibration of the textfield's value. It is especially important for East Asian languages such as Chinese, Japanese, Korean, and other languages with complex characters.

However, sometimes this behaviour is not desired and you can want to enable mask validation on every keystroke (to be like a classic not-composition input). For example, some Android devices with enabled system autocomplete can interpret user's input as part of composition event – waiting for compositionend can be not required for some cases (e.g. entering of numbers or your application is not used by East Asian clients). For this cases, you can use maskitoStrictCompositionPlugin . It applies mask's constraints on ANY intermediate value of IME composition.

Native beforeinput event default behavior is cancelled to process user entered invalid value. This causes native change event to NOT be dispatched by browser. A change event, as opposed to input , is triggered only when user left the field and value was changed during interaction. If you rely on this behavior, add maskitoChangeEventPlugin to your mask configuration. It will dispatch synthetic change event using the same logic.
================================================ FILE: projects/demo/src/pages/documentation/processors/examples/postprocessor-in-action.md ================================================ ```ts import {Maskito} from '@maskito/core'; const numberInput = new Maskito(element, { mask: /^\d+(,\d*)?$/, postprocessors: [ ({value, selection}, initialElementState) => { const [from, to] = selection; const noRepeatedLeadingZeroesValue = value.replace(/^0+/, '0'); const removedCharacters = value.length - noRepeatedLeadingZeroesValue.length; return { value: noRepeatedLeadingZeroesValue, // User types "000000" => 0| selection: [Math.max(from - removedCharacters, 0), Math.max(to - removedCharacters, 0)], }; }, ], }); ``` ================================================ FILE: projects/demo/src/pages/documentation/processors/examples/preprocessor-first-arg-demo.md ================================================ ```ts type firstArgDemo = { // current input's element state BEFORE any changes are applied elementState: { value: string; selection: [from: number, to: number]; }; // new typed characters which is going to be inserted to the element data: string; // can be empty string if it is deletion or validation }; ``` ================================================ FILE: projects/demo/src/pages/documentation/processors/examples/preprocessor-in-action-demo.md ================================================ ```ts import {Maskito} from '@maskito/core'; const numberInput = new Maskito(element, { mask: /^\d+(,\d*)?$/, // digits and comma (as decimal separator) preprocessors: [ ({elementState, data}, actionType) => { const {value, selection} = elementState; return { elementState: { selection, value: value.replace('.', ','), }, data: data.replace('.', ','), }; }, ], }); ``` ================================================ FILE: projects/demo/src/pages/documentation/processors/examples/processor-second-arg-demo.md ================================================ ```ts 'insert' | 'deleteForward' | 'deleteBackward' | 'validation'; ``` ================================================ FILE: projects/demo/src/pages/documentation/processors/processors.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {RouterLink} from '@angular/router'; import {DemoPath} from '@demo/constants'; import {TuiAddonDoc} from '@taiga-ui/addon-doc'; import {TuiLink, TuiNotification} from '@taiga-ui/core'; import {NextStepsComponent} from '../next-steps/next-steps.component'; @Component({ selector: 'processors-doc-page', imports: [NextStepsComponent, RouterLink, TuiAddonDoc, TuiLink, TuiNotification], templateUrl: './processors.template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class ProcessorsDocPageComponent { protected readonly preprocessorFirstArgDemo = import('./examples/preprocessor-first-arg-demo.md'); protected readonly preprocessorsSecondArgDemo = import('./examples/processor-second-arg-demo.md'); protected readonly preprocessorInActionDemo = import('./examples/preprocessor-in-action-demo.md'); protected readonly postprocessorInActionDemo = import('./examples/postprocessor-in-action.md'); protected readonly elementStateDocPage = `/${DemoPath.ElementState}`; } ================================================ FILE: projects/demo/src/pages/documentation/processors/processors.template.html ================================================

MaskitoOptions have optional parameters preprocessors and postprocessors . Both accept array of pure functions. These functions are triggered on every user's input ( beforeinput and input events). They provide an opportunity to modify value before / after the mask is applied.

Preprocessors and postprocessors accept different types of arguments but they have two important similarities:

  • The first argument always contains object with information that you can change. Object with the same properties and updated values can be returned from the processor. It means that you can keep all properties untouched or you can change any or all of these properties.
  • The rest arguments contain information that can be useful to build some complex logic, but you cannot change it.
Before you learn more about processors, you should learn a single prerequisite — meaning of the term "Element state" .

Preprocessors

Each preprocessor is a function that is called before mask is applied.

For example, if user types a new character, all preprocessors will be called first, and only then final value that they returned will be passed into the mask, and finally the mask will accept or reject new typed character and update actual value of the text field.

Preprocessor accepts two arguments:

  1. Object with two properties: elementState and data . Object of the same interface with updated or unchanged properties can be returned from the preprocessor.
  2. Name of the action which triggers current execution. It can be one of the following possible values:

Preprocessor returns an objects of the same interface as the first argument.

Postprocessors

Each postprocessor is a function that is called after the mask is applied. When all preprocessors are already called, all mask operations happened and the input's value is about to be updated. You can change everything manually inside a postprocessor.

Postprocessor accepts two arguments:

  1. Element state after mask had been applied. Postprocessor can return updated element state which would then be reflected by the actual text field.
  2. Initial element state before preprocessors and mask execution. It is a readonly argument, the past cannot be changed...

Postprocessor returns an objects of the same interface as the first argument.

With great power comes great responsibility!

Postprocessor is the final step before input's value update which gives a lot of flexibility. Use postprocessor wisely and return a valid value!

Stacking of multiple processors

The Maskito team likes code decomposition and promotes it! Don't put all complex logic inside a single processor. Both parameters preprocessors and postprocessors accepts array of same type processors. Break your code into the several independent processors so that each processor implements only a single task.

================================================ FILE: projects/demo/src/pages/documentation/real-world-form/index.html ================================================

Real World Form

Name Surname
Enter phone number
Enter password Repeat password
Transaction date Transaction amount
Enter address
================================================ FILE: projects/demo/src/pages/documentation/real-world-form/index.less ================================================ :host { display: flex; justify-content: center; align-items: center; padding-block-start: 2rem; } form { inline-size: 80%; max-inline-size: 40rem; } .password-icon { pointer-events: all; cursor: pointer; } ================================================ FILE: projects/demo/src/pages/documentation/real-world-form/index.ts ================================================ import {ChangeDetectionStrategy, Component, inject} from '@angular/core'; import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import type {MaskitoOptions} from '@maskito/core'; import { maskitoAddOnFocusPlugin, maskitoDateOptionsGenerator, maskitoNumberOptionsGenerator, maskitoRemoveOnBlurPlugin, } from '@maskito/kit'; import {maskitoGetCountryFromNumber, maskitoPhoneOptionsGenerator} from '@maskito/phone'; import {isSafari} from '@ng-web-apis/platform'; import {TUI_IS_IOS, tuiInjectElement} from '@taiga-ui/cdk'; import {TuiButton, TuiFlagPipe, TuiIcon} from '@taiga-ui/core'; import { TuiInputModule, TuiTextareaModule, TuiTextfieldControllerModule, } from '@taiga-ui/legacy'; import metadata from 'libphonenumber-js/min/metadata'; const MONEY_AMOUNT_MASK = maskitoNumberOptionsGenerator({ min: 0, prefix: '$ ', maximumFractionDigits: 2, }); const ONLY_LATIN_LETTERS_RE = /^[a-z]+$/i; @Component({ selector: 'real-world-form', imports: [ MaskitoDirective, ReactiveFormsModule, TuiButton, TuiFlagPipe, TuiIcon, TuiInputModule, TuiTextareaModule, TuiTextfieldControllerModule, ], templateUrl: './index.html', styleUrl: './index.less', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class RealWorldForm { /** * https://github.com/taiga-family/maskito/pull/2165 * TODO: delete after bumping Safari support to 18+ */ protected readonly phonePattern = isSafari(tuiInjectElement()) || inject(TUI_IS_IOS) ? '+[0-9-]{1,20}' : ''; protected readonly form = new FormGroup({ name: new FormControl(''), surname: new FormControl(''), phone: new FormControl(''), password: new FormControl(''), repeatedPassword: new FormControl(''), transactionDate: new FormControl(''), transactionAmount: new FormControl(''), address: new FormControl(''), }); protected nameMask: MaskitoOptions = {mask: ONLY_LATIN_LETTERS_RE}; protected surnameMask: MaskitoOptions = { mask: ONLY_LATIN_LETTERS_RE, postprocessors: [ ({value, selection}) => ({selection, value: value.toUpperCase()}), ], }; protected readonly phoneMask = maskitoPhoneOptionsGenerator({ metadata, strict: false, }); protected passwordMask: MaskitoOptions = {mask: /^\d*(?:[a-z]\d*)?$/i}; protected readonly transactionDateMask = maskitoDateOptionsGenerator({ mode: 'dd/mm/yyyy', }); protected readonly transactionAmountMask: MaskitoOptions = { ...MONEY_AMOUNT_MASK, plugins: [ ...MONEY_AMOUNT_MASK.plugins, maskitoAddOnFocusPlugin('$ '), maskitoRemoveOnBlurPlugin('$ '), ], }; protected readonly addressMask: MaskitoOptions = {mask: /^[a-z1-9\s.,/]+$/i}; protected showPassword = false; protected get countryIsoCode(): string { return maskitoGetCountryFromNumber(this.form.value.phone ?? '', metadata) ?? ''; } protected log(something: any): void { console.info(something); } } ================================================ FILE: projects/demo/src/pages/documentation/supported-input-types/examples/password/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiHint} from '@taiga-ui/core'; import {TuiInputPasswordModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'input-type-password-example', imports: [ FormsModule, MaskitoDirective, TuiHint, TuiInputPasswordModule, TuiTextfieldControllerModule, ], template: ` Enter password `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class InputPasswordDocExample { protected readonly maskitoOptions = mask; protected value = ''; } ================================================ FILE: projects/demo/src/pages/documentation/supported-input-types/examples/password/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; export default {mask: [/\d/, /\d/, /\d/]} satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/documentation/supported-input-types/examples/search/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'input-type-search-example', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` Enter any english word `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class InputSearchDocExample { protected readonly maskitoOptions = mask; protected value = ''; } ================================================ FILE: projects/demo/src/pages/documentation/supported-input-types/examples/search/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; export default {mask: /^[a-z]+$/i} satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/documentation/supported-input-types/examples/tel/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiFlagPipe} from '@taiga-ui/core'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'input-type-tel-example', imports: [ FormsModule, MaskitoDirective, TuiFlagPipe, TuiInputModule, TuiTextfieldControllerModule, ], template: ` Enter phone number Flag of the United States `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class InputTelDocExample { protected readonly maskitoOptions = mask; protected value = ''; } ================================================ FILE: projects/demo/src/pages/documentation/supported-input-types/examples/tel/mask.ts ================================================ import {maskitoPhoneOptionsGenerator} from '@maskito/phone'; import metadata from 'libphonenumber-js/metadata.min.json'; export default maskitoPhoneOptionsGenerator({ metadata, countryIsoCode: 'US', }); ================================================ FILE: projects/demo/src/pages/documentation/supported-input-types/examples/text/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'input-type-text-example', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` Enter time `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class InputTextDocExample { protected readonly maskitoOptions = mask; protected value = ''; } ================================================ FILE: projects/demo/src/pages/documentation/supported-input-types/examples/text/mask.ts ================================================ import {maskitoTimeOptionsGenerator} from '@maskito/kit'; export default maskitoTimeOptionsGenerator({mode: 'HH:MM'}); ================================================ FILE: projects/demo/src/pages/documentation/supported-input-types/examples/url/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'input-type-url-example', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` Enter url `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class InputURLDocExample { protected readonly maskitoOptions = mask; protected value = ''; } ================================================ FILE: projects/demo/src/pages/documentation/supported-input-types/examples/url/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; export default { // oversimplified version of url mask for demo purposes mask: /^[\w/:.@]+$/, } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/documentation/supported-input-types/supported-input-types.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {DocExamplePrimaryTab} from '@demo/constants'; import {TuiAddonDoc, type TuiRawLoaderContent} from '@taiga-ui/addon-doc'; import {TuiLink, TuiNotification} from '@taiga-ui/core'; import {InputPasswordDocExample} from './examples/password/component'; import {InputSearchDocExample} from './examples/search/component'; import {InputTelDocExample} from './examples/tel/component'; import {InputTextDocExample} from './examples/text/component'; import {InputURLDocExample} from './examples/url/component'; @Component({ selector: 'supported-input-types-doc-page', imports: [ InputPasswordDocExample, InputSearchDocExample, InputTelDocExample, InputTextDocExample, InputURLDocExample, TuiAddonDoc, TuiLink, TuiNotification, ], templateUrl: './supported-input-types.template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class SupportedInputTypesDocPageComponent { protected readonly textTypeExample: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/text/mask.ts?raw', { with: {loader: 'text'}, }), }; protected readonly telTypeExample: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/tel/mask.ts?raw', { with: {loader: 'text'}, }), }; protected readonly passwordTypeExample: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/password/mask.ts?raw', { with: {loader: 'text'}, }), }; protected readonly urlTypeExample: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/url/mask.ts?raw', { with: {loader: 'text'}, }), }; protected readonly searchTypeExample: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/search/mask.ts?raw', { with: {loader: 'text'}, }), }; protected getInput(type: HTMLInputElement['type']): string { return ``; } } ================================================ FILE: projects/demo/src/pages/documentation/supported-input-types/supported-input-types.template.html ================================================
Maskito supports only limited types of HTMLInputElement due to some browser limitations!

Maskito accepts only the types whose support the following native properties/methods:

  • selectionStart
  • selectionEnd
  • setSelectionRange

According to the WHATWG forms spec they apply only to inputs of types text , search , URL , tel and password .
All other types will not work properly with Maskito!

All examples below are demonstrations to see different supported types in action.

{{ getInput('text') }} is the default, the simplest and the most popular type of input-element.
Use it if you don't know which type to choose.
{{ getInput('tel') }} is a control for entering a telephone number.
Displays a telephone keypad in some devices with dynamic keypads.
{{ getInput('password') }} is a single-line text field whose value is obscured. {{ getInput('url') }} is a field for entering a URL.
Looks like a text input, but has relevant keyboard in supporting browsers and devices with dynamic keyboards.
{{ getInput('search') }} is a single-line text field for entering search strings.
Displays a search icon instead of enter key on some devices with dynamic keypads.
================================================ FILE: projects/demo/src/pages/documentation/transformer/examples/utility-in-action-demo.md ================================================ ```ts import {maskitoTransform} from '@maskito/core'; import {maskitoNumberOptionsGenerator} from '@maskito/kit'; const maskitoOptions = maskitoNumberOptionsGenerator({ thousandSeparator: ' ', }); const definitelyValidValue = maskitoTransform('1000000', maskitoOptions); console.info(definitelyValidValue); // '1 000 000' // Framework agnostic way | index.ts inputElement.value = definitelyValidValue; // Angular way | app.component.ts this.formControl.patchValue(definitelyValidValue); ``` ================================================ FILE: projects/demo/src/pages/documentation/transformer/transformer.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {TuiAddonDoc} from '@taiga-ui/addon-doc'; import {TuiNotification} from '@taiga-ui/core'; import {NextStepsComponent} from '../next-steps/next-steps.component'; @Component({ selector: 'transformer-doc-page', imports: [NextStepsComponent, TuiAddonDoc, TuiNotification], templateUrl: './transformer.template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class TransformerDocPageComponent { protected readonly utilityInActionDemo = import('./examples/utility-in-action-demo.md'); } ================================================ FILE: projects/demo/src/pages/documentation/transformer/transformer.template.html ================================================

Maskito libraries were created to prevent user from typing invalid value.
Maskito listens beforeinput and input events. Programmatic (by developer) changes of input's value don't trigger these events!

Maskito is based on the assumption that developer is capable to programmatically patch input with valid value!

If you need to programmatically patch input's value but you are not sure that your value is valid (for example, you get it from the server), you should use maskitoTransform utility .

================================================ FILE: projects/demo/src/pages/documentation/what-is-maskito/what-is-maskito.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {RouterLink} from '@angular/router'; import {DemoPath} from '@demo/constants'; import {TuiAddonDoc} from '@taiga-ui/addon-doc'; import {TuiLink, TuiSurface, TuiTitle} from '@taiga-ui/core'; import {TuiAvatar} from '@taiga-ui/kit'; import {TuiCardLarge, TuiHeader} from '@taiga-ui/layout'; @Component({ selector: 'what-is-maskito-doc-page', imports: [ RouterLink, TuiAddonDoc, TuiAvatar, TuiCardLarge, TuiHeader, TuiLink, TuiSurface, TuiTitle, ], templateUrl: './what-is-maskito.template.html', styleUrl: './what-is-maskito.style.less', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class WhatIsMaskitoDocPageComponent { protected readonly maskitoLibrariesDocPage = `/${DemoPath.MaskitoLibraries}`; protected readonly coreConceptsOverviewDocPage = `/${DemoPath.CoreConceptsOverview}`; protected readonly angularDocPage = `/${DemoPath.Angular}`; protected readonly reactDocPage = `/${DemoPath.React}`; protected readonly vueDocPage = `/${DemoPath.Vue}`; } ================================================ FILE: projects/demo/src/pages/documentation/what-is-maskito/what-is-maskito.style.less ================================================ .cards { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 2rem; & [tuiCardLarge] { flex: 1; min-inline-size: 18rem; } } ================================================ FILE: projects/demo/src/pages/documentation/what-is-maskito/what-is-maskito.template.html ================================================
Maskito is a collection of libraries, built with TypeScript. It helps you to create an input mask which ensures that users type values according to predefined format.

Core concepts of the libraries are simple but they provide flexible API to set any format you wish: numbers, phone, date, credit card number etc.

Why Maskito?

  • Maskito supports all user’s interactions with text fields: basic typing and deleting via keyboard, pasting, dropping text inside with a pointer, browser autofill, predictive text from mobile native keyboard.
  • Maskito is robust. The whole project is developed with strict TypeScript mode. Our code is covered by hundreds of Cypress tests.
  • Server Side Rendering and Shadow DOM support.
  • You can use it with HTMLInputElement / HTMLTextAreaElement or even with [contenteditable] element.
  • Maskito core is zero-dependency package. You can mask input in your vanilla JavaScript project. However, we have separate packages for Angular , React and Vue as well.
  • Maskito includes optional framework-agnostic package with configurable ready-to-use masks.

No textfield with invalid value! Use Maskito. Mask it!

Learn about Maskito

================================================ FILE: projects/demo/src/pages/frameworks/angular/angular-doc.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {RouterLink} from '@angular/router'; import {DemoPath} from '@demo/constants'; import {MaskitoDirective} from '@maskito/angular'; import {TuiAddonDoc, type TuiRawLoaderContent} from '@taiga-ui/addon-doc'; import {TuiLink, TuiNotification} from '@taiga-ui/core'; import {NestedDocExample1} from './examples/1-nested/component'; import {NestedDocExample2} from './examples/2-nested/component'; import {ProgrammaticallyDocExample3} from './examples/3-programmatically/component'; import {PipeDocExample4} from './examples/4-pipe/component'; import {UnmaskDocExample5} from './examples/5-custom-unmask-handler'; import {PatternDocExample6} from './examples/6-pattern/component'; @Component({ selector: 'angular-doc-page', imports: [ MaskitoDirective, NestedDocExample1, NestedDocExample2, PatternDocExample6, PipeDocExample4, ProgrammaticallyDocExample3, RouterLink, TuiAddonDoc, TuiLink, TuiNotification, UnmaskDocExample5, ], templateUrl: './angular-doc.template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class AngularDocPageComponent { protected readonly coreConceptsOverviewDocPage = `/${DemoPath.CoreConceptsOverview}`; protected readonly importMaskitoExample = import('./examples/import-maskito.md'); protected readonly basicDirectiveApproach = import('./examples/basic-directive-approach.md'); protected readonly customInputExample = import('./examples/custom-input-example.md'); protected readonly nestedInputExample: Record = { TypeScript: import('./examples/1-nested/component.ts?raw', { with: {loader: 'text'}, }), Default: import('./examples/1-nested/template.html'), Custom: import('./examples/2-nested/template.html'), }; protected readonly programmaticallyExample: Record = { TypeScript: import('./examples/3-programmatically/component.ts?raw', { with: {loader: 'text'}, }), HTML: import('./examples/3-programmatically/template.html'), }; protected readonly pipeExample: Record = { TypeScript: import('./examples/4-pipe/component.ts?raw', { with: {loader: 'text'}, }), HTML: import('./examples/4-pipe/template.html'), }; protected readonly customUnmaskHandlerExample: Record = { 'index.html': import('./examples/5-custom-unmask-handler/index.html'), 'index.ts': import('./examples/5-custom-unmask-handler/index.ts?raw', { with: {loader: 'text'}, }), 'unmask.directive.ts': import( './examples/5-custom-unmask-handler/unmask.directive.ts?raw', {with: {loader: 'text'}} ), }; protected readonly patternDirectiveExample: Record = { TypeScript: import('./examples/6-pattern/component.ts?raw', { with: {loader: 'text'}, }), HTML: import('./examples/6-pattern/template.html'), }; } ================================================ FILE: projects/demo/src/pages/frameworks/angular/angular-doc.template.html ================================================ @maskito/angular is a light-weighted library to use Maskito in an Angular-way.
Prerequisites

To get the most out of this guide, you should review the topic "Core Concepts" first.

Write less code

  • No need to query element from DOM. Just pass all required options to [maskito] directive.
  • No need to worry about clean-ups. All created event listeners are automatically removed after element is detached from DOM.

Basic directive approach

Use it when you have direct access to native input element.

Nested input element

Pass a predicate to maskito to find input element for you, if you do not have a direct access to it.

By default maskito will try to find input/textarea by querying its host: host.querySelector('input,textarea') so that might be sufficient. Use custom predicate if you need custom logic.
Default behavior is enough for Taiga UI inputs
Custom predicate is required if target input is not the first on in the DOM
When directly on native input/textarea tag, MaskitoDirective formats value set programmatically with Angular forms. According to W3C specification, textfield value should always be only a string -type (not number -type, not object -type or etc.). However, you can sometimes need to store value without mask in Angular form control. This example demonstrates how easily any Angular Control Value Accessor (default one or any custom one from a third-party UI Kit) can be monkey-patched to achieve this goal.
  1. Install libraries
  2. Import MaskitoDirective to your component / module
================================================ FILE: projects/demo/src/pages/frameworks/angular/examples/1-nested/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import type {MaskitoElementPredicate, MaskitoOptions} from '@maskito/core'; import {TuiInputModule} from '@taiga-ui/legacy'; @Component({ selector: 'nested-doc-example-1', imports: [FormsModule, MaskitoDirective, TuiInputModule], templateUrl: './template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class NestedDocExample1 { protected value = ''; public readonly nameMask: MaskitoOptions = { mask: /^[a-z\s]+$/i, postprocessors: [ ({value, selection}) => ({value: value.toUpperCase(), selection}), ], }; public readonly predicate: MaskitoElementPredicate = (element) => element.querySelector('tui-input input')!; } ================================================ FILE: projects/demo/src/pages/frameworks/angular/examples/1-nested/template.html ================================================ Name on the card ================================================ FILE: projects/demo/src/pages/frameworks/angular/examples/2-nested/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {TuiLabel} from '@taiga-ui/core'; import {TuiCheckbox} from '@taiga-ui/kit'; import {TuiInputModule} from '@taiga-ui/legacy'; @Component({ selector: 'nested-doc-example-2', imports: [FormsModule, TuiCheckbox, TuiInputModule, TuiLabel], templateUrl: './template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class NestedDocExample2 { protected show = false; protected value = ''; } ================================================ FILE: projects/demo/src/pages/frameworks/angular/examples/2-nested/template.html ================================================ Name on the card ================================================ FILE: projects/demo/src/pages/frameworks/angular/examples/3-programmatically/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormControl, ReactiveFormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {maskitoNumberOptionsGenerator} from '@maskito/kit'; @Component({ selector: 'programmatically-doc-example-3', imports: [MaskitoDirective, ReactiveFormsModule], templateUrl: './template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProgrammaticallyDocExample3 { protected readonly control = new FormControl(''); protected readonly maskito = maskitoNumberOptionsGenerator({ maximumFractionDigits: 2, }); protected setValue(): void { this.control.setValue( '12345.6789', // This value will be formatted to "12 345.67" ); } } ================================================ FILE: projects/demo/src/pages/frameworks/angular/examples/3-programmatically/template.html ================================================ ================================================ FILE: projects/demo/src/pages/frameworks/angular/examples/4-pipe/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {MaskitoPipe} from '@maskito/angular'; import {maskitoNumberOptionsGenerator} from '@maskito/kit'; @Component({ selector: 'pipe-doc-example-4', imports: [MaskitoPipe], templateUrl: './template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class PipeDocExample4 { protected value = 12345.67; protected readonly options = maskitoNumberOptionsGenerator({ maximumFractionDigits: 2, }); } ================================================ FILE: projects/demo/src/pages/frameworks/angular/examples/4-pipe/template.html ================================================ Balance: ${{ value | maskito: options }} ================================================ FILE: projects/demo/src/pages/frameworks/angular/examples/5-custom-unmask-handler/index.html ================================================

Control value: {{ value }}

================================================ FILE: projects/demo/src/pages/frameworks/angular/examples/5-custom-unmask-handler/index.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import { maskitoNumberOptionsGenerator, type MaskitoNumberParams, maskitoParseNumber, maskitoStringifyNumber, } from '@maskito/kit'; import {UnmaskDirective} from './unmask.directive'; const NUMBER_PARAMS: MaskitoNumberParams = { maximumFractionDigits: 2, thousandSeparator: '.', decimalSeparator: ',', }; @Component({ selector: 'custom-unmask-handler-doc-example-5', imports: [FormsModule, MaskitoDirective, UnmaskDirective], templateUrl: './index.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class UnmaskDocExample5 { /** * Pay attention, this variable (form control value) always contains only NUMBER-type values. * Despite it, textfield's value is always prettified formatted STRING. */ protected value = 1000.42; protected readonly maskito = maskitoNumberOptionsGenerator(NUMBER_PARAMS); /** * `maskitoParseNumber` is built-in utility to convert * entered number (as prettified formatted STRING) to number-type value */ protected readonly unmaskHandler = (x: string): number => maskitoParseNumber(x, NUMBER_PARAMS); // `maskitoStringifyNumber` implements the reverse transformation protected readonly stringify = (x: number): string => maskitoStringifyNumber(x, NUMBER_PARAMS); } ================================================ FILE: projects/demo/src/pages/frameworks/angular/examples/5-custom-unmask-handler/unmask.directive.ts ================================================ import {type AfterViewInit, Directive, inject} from '@angular/core'; import {DefaultValueAccessor} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {maskitoTransform} from '@maskito/core'; import {identity} from 'rxjs'; @Directive({ selector: '[maskito][unmaskHandler]', inputs: ['unmaskHandler', 'stringifyHandler'], }) export class UnmaskDirective implements AfterViewInit { private readonly accessor = inject(DefaultValueAccessor); private readonly maskitoDirective = inject(MaskitoDirective); public unmaskHandler: (value: string) => any = identity; public stringifyHandler: (value: any) => string = (value) => { const options = this.maskitoDirective.options(); return options ? maskitoTransform(String(value), options) : value; }; public ngAfterViewInit(): void { const originalOnChange = this.accessor.onChange.bind(this.accessor); const originalWriteValue = this.accessor.writeValue.bind(this.accessor); this.accessor.onChange = (value) => originalOnChange(this.unmaskHandler(value)); this.accessor.writeValue = (value) => originalWriteValue(this.stringifyHandler(value)); } } ================================================ FILE: projects/demo/src/pages/frameworks/angular/examples/6-pattern/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoPattern} from '@maskito/angular'; @Component({ selector: 'pattern-doc-example-6', imports: [FormsModule, MaskitoPattern], templateUrl: './template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class PatternDocExample6 { protected name = ''; protected cvc = ''; protected regExp = /^[a-z\s]+$/i; } ================================================ FILE: projects/demo/src/pages/frameworks/angular/examples/6-pattern/template.html ================================================ ================================================ FILE: projects/demo/src/pages/frameworks/angular/examples/basic-directive-approach.md ================================================ ```ts import {Component} from '@angular/core'; import {MaskitoDirective} from '@maskito/angular'; import {MaskitoOptions} from '@maskito/core'; @Component({ selector: 'your-component', template: ` `, imports: [MaskitoDirective], }) export class YourComponent { readonly maskitoOptions: MaskitoOptions = { mask: /^\d+$/, }; } ``` ================================================ FILE: projects/demo/src/pages/frameworks/angular/examples/custom-input-example.md ================================================ ```ts import {Component} from '@angular/core'; import {MaskitoDirective} from '@maskito/angular'; import {MaskitoOptions, MaskitoElementPredicate} from '@maskito/core'; @Component({ selector: 'your-component', template: ` Using maskito with another library `, imports: [MaskitoDirective], }) export class YourComponent { readonly maskitoOptions: MaskitoOptions = { mask: /^\d+$/, }; readonly predicate: MaskitoElementPredicate = (element) => element.querySelector('input[id="my-input"]')!; } ``` ================================================ FILE: projects/demo/src/pages/frameworks/angular/examples/import-maskito.md ================================================ ```ts import {Component} from '@angular/core'; import {MaskitoDirective} from '@maskito/angular'; @Component({ // ... imports: [ MaskitoDirective, // ... ], }) export class YourComponent {} ``` ================================================ FILE: projects/demo/src/pages/frameworks/react/examples/1-use-maskito-basic-usage/example.component.tsx ================================================ import {isPlatformBrowser} from '@angular/common'; import {ChangeDetectionStrategy, Component, ElementRef, inject, PLATFORM_ID} from '@angular/core'; import {createRoot} from 'react-dom/client'; import {App} from './useMaskitoBasicUsage'; @Component({ selector: 'react-example-1', template: '', changeDetection: ChangeDetectionStrategy.OnPush, host: { 'comment-for-devtools': 'Everything inside this tag is really rendered by `react-dom` library', }, }) export class ReactExample1 { constructor() { if (isPlatformBrowser(inject(PLATFORM_ID))) { createRoot(inject(ElementRef).nativeElement).render(); } } } ================================================ FILE: projects/demo/src/pages/frameworks/react/examples/1-use-maskito-basic-usage/useMaskitoBasicUsage.tsx ================================================ import type {MaskitoOptions} from '@maskito/core'; import {useMaskito} from '@maskito/react'; import type {ComponentType} from 'react'; const digitsOnlyMask: MaskitoOptions = {mask: /^\d+$/}; export const App: ComponentType = () => { const inputRef = useMaskito({options: digitsOnlyMask}); return ( ); }; ================================================ FILE: projects/demo/src/pages/frameworks/react/examples/2-element-predicate/awesomeInput.tsx ================================================ import {forwardRef, type InputHTMLAttributes} from 'react'; const hiddenInputStyles = {display: 'none'}; export const AwesomeInput = forwardRef>((props, ref) => (
)); ================================================ FILE: projects/demo/src/pages/frameworks/react/examples/2-element-predicate/example.component.tsx ================================================ import {isPlatformBrowser} from '@angular/common'; import {ChangeDetectionStrategy, Component, ElementRef, inject, PLATFORM_ID} from '@angular/core'; import {createRoot} from 'react-dom/client'; import {App} from '.'; @Component({ selector: 'react-example-2', template: '', changeDetection: ChangeDetectionStrategy.OnPush, host: { 'comment-for-devtools': 'Everything inside this tag is really rendered by `react-dom` library', }, }) export class ReactExample2 { constructor() { if (isPlatformBrowser(inject(PLATFORM_ID))) { createRoot(inject(ElementRef).nativeElement).render(); } } } ================================================ FILE: projects/demo/src/pages/frameworks/react/examples/2-element-predicate/index.tsx ================================================ import type {MaskitoElementPredicate} from '@maskito/core'; import {maskitoDateOptionsGenerator} from '@maskito/kit'; import {useMaskito} from '@maskito/react'; import type {ComponentType} from 'react'; import {AwesomeInput} from './awesomeInput'; const options = maskitoDateOptionsGenerator({mode: 'dd/mm/yyyy'}); const elementPredicate: MaskitoElementPredicate = (host) => host.querySelector('input.real-input')!; export const App: ComponentType = () => { const inputRef = useMaskito({options, elementPredicate}); return ( ); }; ================================================ FILE: projects/demo/src/pages/frameworks/react/examples/3-merge-ref/index.tsx ================================================ import type {MaskitoOptions} from '@maskito/core'; import {useMaskito} from '@maskito/react'; import {type ComponentType, useRef} from 'react'; const digitsOnlyMask: MaskitoOptions = {mask: /^\d+$/}; export const App: ComponentType = () => { const externalRef = useRef(null); const maskitoRef = useMaskito({options: digitsOnlyMask}); return ( { maskitoRef(node); externalRef.current = node; }} placeholder="Enter a number" /> ); }; ================================================ FILE: projects/demo/src/pages/frameworks/react/examples/3-react-hook-form/index.tsx ================================================ import type {MaskitoOptions} from '@maskito/core'; import {maskitoNumberOptionsGenerator} from '@maskito/kit'; import {useMaskito} from '@maskito/react'; import type {ComponentType} from 'react'; // @ts-ignore import {useForm} from 'react-hook-form'; import {withMaskitoRegister} from './with-maskito-register'; const options: MaskitoOptions = maskitoNumberOptionsGenerator({maximumFractionDigits: 2}); export const App: ComponentType = () => { const maskitoRef = useMaskito({options}); const {register, watch} = useForm(); const value = watch('controlName'); console.info('[controlName]: ', value); return ( ); }; ================================================ FILE: projects/demo/src/pages/frameworks/react/examples/3-react-hook-form/with-maskito-register.ts ================================================ import type {RefCallback} from 'react'; // @ts-ignore import type {UseFormRegisterReturn} from 'react-hook-form'; const noop = async (): Promise => {}; export const withMaskitoRegister = ( registerResult: UseFormRegisterReturn, maskitoRef: RefCallback, ): UseFormRegisterReturn & {onInput: UseFormRegisterReturn['onChange']} => { const ref: RefCallback = (node): void => { registerResult.ref(node); maskitoRef(node); }; return { ...registerResult, ref, onInput: registerResult.onChange, onChange: noop, }; }; ================================================ FILE: projects/demo/src/pages/frameworks/react/examples/best-bad-practice.md ================================================ ```ts // Best Practice ✅ useMaskito({ options: maskitoOptions, elementPredicate: predicate, }); // Anti-Pattern ❌ useMaskito({ options: {mask: /^.*$/}, elementPredicate: () => e.querySelector('input#my-input'), }); ``` ================================================ FILE: projects/demo/src/pages/frameworks/react/examples/controlled-input.md ================================================ ```tsx import {useState} from 'react'; import {useMaskito} from '@maskito/react'; const digitsOnlyMask: MaskitoOptions = { mask: /^\d+$/, }; function App() { const inputRef = useMaskito({options: digitsOnlyMask}); const [value, setValue] = useState(''); // Use `onInput` handler to build controlled input return ( setValue(e.currentTarget.value)} /> ); } ``` ================================================ FILE: projects/demo/src/pages/frameworks/react/examples/merge-ref.md ================================================ ```tsx import {useMaskito} from '@maskito/react'; const options: MaskitoOptions = { mask: /^\d+$/, }; function App() { const anyExternalRef = useRef(null); const maskitoRef = useMaskito({options}); return ( { maskitoRef(node); anyExternalRef.current = node; }} /> ); } ``` ================================================ FILE: projects/demo/src/pages/frameworks/react/react-doc.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {RouterLink} from '@angular/router'; import {DemoPath} from '@demo/constants'; import {TuiAddonDoc, type TuiRawLoaderContent} from '@taiga-ui/addon-doc'; import {TuiLink, TuiNotification} from '@taiga-ui/core'; import {TuiTabs} from '@taiga-ui/kit'; import {ReactExample1} from './examples/1-use-maskito-basic-usage/example.component'; import {ReactExample2} from './examples/2-element-predicate/example.component'; @Component({ selector: 'react-doc-page', imports: [ ReactExample1, ReactExample2, RouterLink, TuiAddonDoc, TuiLink, TuiNotification, TuiTabs, ], templateUrl: './react-doc.template.html', styleUrl: './react-doc.style.less', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class ReactDocPageComponent { protected readonly coreConceptsOverviewDocPage = `/${DemoPath.CoreConceptsOverview}`; protected readonly useMaskitoBasicUsage = import( './examples/1-use-maskito-basic-usage/useMaskitoBasicUsage.tsx?raw', {with: {loader: 'text'}} ); protected readonly controlledInputDemo = import('./examples/controlled-input.md'); protected readonly mergeRefDemo = import('./examples/merge-ref.md'); protected readonly elementPredicateExample: Record = { 'index.tsx': import('./examples/2-element-predicate/index.tsx?raw', { with: {loader: 'text'}, }), 'awesome-input.tsx': import( './examples/2-element-predicate/awesomeInput.tsx?raw', {with: {loader: 'text'}} ), }; protected readonly reactHookFormExample: Record = { 'index.tsx': import('./examples/3-react-hook-form/index.tsx?raw', { with: {loader: 'text'}, }), 'with-maskito-register.ts': import( './examples/3-react-hook-form/with-maskito-register.ts?raw', {with: {loader: 'text'}} ), }; protected readonly bestBadPractice = import('./examples/best-bad-practice.md'); } ================================================ FILE: projects/demo/src/pages/frameworks/react/react-doc.style.less ================================================ .no-tabs { padding-block-start: 0; ::ng-deep .t-example { margin-block-start: 0; } } section { margin-block-start: 3.5rem; } ================================================ FILE: projects/demo/src/pages/frameworks/react/react-doc.template.html ================================================

@maskito/react is a light-weighted library to use Maskito in an React-way.

Prerequisites

To get the most out of this guide, you should review the topic "Core Concepts" first.

Getting Started

Install libraries

and use Maskito

See the result of above code example in action:

Controlled masked input

Maskito core is developed as framework-agnostic library. It does not depend on any JS-framework's peculiarities. It uses only native browser API. That is why we strongly recommends use native onInput instead of React-specific onChange event handler. Do not worry, both events works similarly! Read more about it in the official React documentation.

React-specific onChange is supported by Maskito too but usage of onInput handler is a more robust solution!

Merge Maskito ref with the third-party ref

Do you need to use multiple hooks that return refs which both should be attached to the masked textfield?

Use ref callback !

Query nested input element

Pass a predicate to elementPredicate to find input element for you, if you do not have a direct access to it. For example, you use component from some UI Kit library.

By default Maskito will try to find input/textarea by querying its host: host.querySelector('input,textarea') so that might be sufficient. Use custom predicate if you need custom logic.

Integration with third-party library for forms

There is not silver bullet how to integrate Maskito with any library for form-building. Explore all examples above – the provided knowledge about element predicate, ref merging and OnInput event will help you a lot to achieve it.

This example demonstrates how to use Maskito with popular library react-hook-form .

@switch (tabs.activeItemIndex) { @case (0) { } @case (1) { } }

Best practices & Anti-Patterns

Pass named variables to avoid unnecessary hook runs with Maskito recreation:

================================================ FILE: projects/demo/src/pages/frameworks/vue/examples/best-bad-practice.md ================================================ ```html ``` ================================================ FILE: projects/demo/src/pages/frameworks/vue/examples/query-nested-input.md ================================================ ```ts import {createApp} from 'vue'; import {maskitoNumberOptionsGenerator} from '@maskito/kit'; import {maskito} from '@maskito/vue'; createApp({ template: '', directives: {maskito}, data: () => ({ value: '123456', options: { ...maskitoNumberOptionsGenerator(), elementPredicate: (host) => host.querySelector('input')!, }, }), }).mount('#vue'); ``` ================================================ FILE: projects/demo/src/pages/frameworks/vue/examples/use-maskito-basic-usage.md ================================================ ```ts import {createApp} from 'vue'; import {maskitoNumberOptionsGenerator} from '@maskito/kit'; import {maskito} from '@maskito/vue'; createApp({ template: '', directives: {maskito}, data: () => ({ value: '123_456', options: maskitoNumberOptionsGenerator({ thousandSeparator: '_', }), }), }).mount('#vue'); ``` ================================================ FILE: projects/demo/src/pages/frameworks/vue/examples/vue-1/component.ts ================================================ import {afterNextRender, ChangeDetectionStrategy, Component} from '@angular/core'; import {maskitoNumberOptionsGenerator} from '@maskito/kit'; import {maskito} from '@maskito/vue'; @Component({ selector: 'vue-example-1', template: '
', changeDetection: ChangeDetectionStrategy.OnPush, }) export class VueExample1 { protected readonly csrOnly = afterNextRender(async () => import('vue').then(({createApp}) => { createApp({ template: '', directives: {maskito}, data: () => ({ value: '123_456', options: maskitoNumberOptionsGenerator({thousandSeparator: '_'}), }), }).mount('#vue'); }), ); } ================================================ FILE: projects/demo/src/pages/frameworks/vue/vue-doc.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {RouterLink} from '@angular/router'; import {DemoPath} from '@demo/constants'; import {TuiAddonDoc} from '@taiga-ui/addon-doc'; import {TuiLink, TuiNotification} from '@taiga-ui/core'; import {VueExample1} from './examples/vue-1/component'; @Component({ selector: 'vue-doc-page', imports: [RouterLink, TuiAddonDoc, TuiLink, TuiNotification, VueExample1], templateUrl: './vue-doc.template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class VueDocPageComponent { protected readonly coreConceptsOverviewDocPage = `/${DemoPath.CoreConceptsOverview}`; protected readonly useMaskitoBasicUsage = import('./examples/use-maskito-basic-usage.md'); protected readonly queryNestedInputDemo = import('./examples/query-nested-input.md'); protected readonly bestBadPractice = import('./examples/best-bad-practice.md'); } ================================================ FILE: projects/demo/src/pages/frameworks/vue/vue-doc.template.html ================================================

@maskito/vue is a light-weighted library to use Maskito in as a Vue directive.

Prerequisites

To get the most out of this guide, you should review the topic "Core Concepts" first.

Getting Started

Install libraries

and use Maskito

@defer { } @placeholder { }

Query nested input element

Pass a predicate to elementPredicate to find input element for you, if you do not have a direct access to it. For example, you use component from some UI Kit library.

By default Maskito will try to find input/textarea by querying its host: host.querySelector('input,textarea') so that might be sufficient. Use custom predicate if you need custom logic.

Best practices & Anti-Patterns

Avoid inlining options object, otherwise Maskito will be recreated on every update:

================================================ FILE: projects/demo/src/pages/kit/date/date-mask-doc.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormControl, ReactiveFormsModule} from '@angular/forms'; import {DocExamplePrimaryTab} from '@demo/constants'; import {MaskitoDirective} from '@maskito/angular'; import type {MaskitoOptions} from '@maskito/core'; import { type MaskitoDateMode, maskitoDateOptionsGenerator, type MaskitoDateParams, } from '@maskito/kit'; import {TuiAddonDoc, type TuiRawLoaderContent} from '@taiga-ui/addon-doc'; import {TuiLink, TuiNotification} from '@taiga-ui/core'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import {DateMaskDocExample1} from './examples/1-localization/component'; import {DateMaskDocExample2} from './examples/2-min-max/component'; @Component({ selector: 'date-mask-doc', imports: [ DateMaskDocExample1, DateMaskDocExample2, MaskitoDirective, ReactiveFormsModule, TuiAddonDoc, TuiInputModule, TuiLink, TuiNotification, TuiTextfieldControllerModule, ], templateUrl: './date-mask-doc.template.html', styleUrl: './date-mask-doc.style.less', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class DateMaskDocComponent implements Required { protected apiPageControl = new FormControl(''); protected readonly maskitoParseStringifyDateDemo = import('./examples/maskito-parse-stringify-date-demo.md'); protected readonly dateLocalization: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/1-localization/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly dateMinMax: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/2-min-max/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly modeOptions = [ 'dd/mm/yyyy', 'mm/dd/yyyy', 'yyyy/mm/dd', 'dd/mm', 'mm/dd', 'mm/yy', 'mm/yyyy', 'yyyy/mm', 'yyyy', ] as const satisfies readonly MaskitoDateMode[]; protected readonly separatorOptions = ['.', '/', '-'] as const; protected readonly minMaxOptions = [ '0001-01-01', '9999-12-31', '2000-01-01', '2025-05-10', ] as const; protected minStr: string = this.minMaxOptions[0]; protected maxStr: string = this.minMaxOptions[1]; public mode: MaskitoDateMode = this.modeOptions[0]; public separator: string = this.separatorOptions[0]; public min = new Date(this.minStr); public max = new Date(this.maxStr); public maskitoOptions: MaskitoOptions = maskitoDateOptionsGenerator(this); protected updateDate(): void { this.min = new Date(this.minStr); this.max = new Date(this.maxStr); this.updateOptions(); } protected updateOptions(): void { this.maskitoOptions = maskitoDateOptionsGenerator(this); } } ================================================ FILE: projects/demo/src/pages/kit/date/date-mask-doc.style.less ================================================ .input-date { max-inline-size: 25rem; &:not(:last-child) { margin-block-end: 1rem; } } ================================================ FILE: projects/demo/src/pages/kit/date/date-mask-doc.template.html ================================================ Use maskitoDateOptionsGenerator to create a mask for date input.
Despite the name of the mask, element's raw value is still string.

Use maskitoParseDate to get date from masked string.

Use maskitoStringifyDate to get the masked string from date.

Use mode and separator properties to get a mask with a locale specific representation of dates. Properties min and max allow you to set the earliest and the latest available dates. They accept native Date .
Enter date Date format mode Symbol for separating date-segments (days, months, years)

Default: . (dot)

Earliest date

Default: new Date('0001-01-01')

Latest date

Default: new Date('9999-12-31')

================================================ FILE: projects/demo/src/pages/kit/date/examples/1-localization/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'date-mask-doc-example-1', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` Localization `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class DateMaskDocExample1 { protected value = '2005/10/21'; protected readonly filler = 'yyyy/mm/dd'; protected readonly mask = mask; } ================================================ FILE: projects/demo/src/pages/kit/date/examples/1-localization/mask.ts ================================================ import {maskitoDateOptionsGenerator} from '@maskito/kit'; export default maskitoDateOptionsGenerator({mode: 'yyyy/mm/dd', separator: '/'}); ================================================ FILE: projects/demo/src/pages/kit/date/examples/2-min-max/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'date-mask-doc-example-2', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` Date `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class DateMaskDocExample2 { protected value = '20.01.2023'; protected readonly filler = 'dd.mm.yyyy'; protected readonly mask = mask; } ================================================ FILE: projects/demo/src/pages/kit/date/examples/2-min-max/mask.ts ================================================ import {maskitoDateOptionsGenerator} from '@maskito/kit'; export default maskitoDateOptionsGenerator({ mode: 'dd/mm/yyyy', min: new Date(2000, 0, 1), max: new Date(2025, 4, 10), }); ================================================ FILE: projects/demo/src/pages/kit/date/examples/maskito-parse-stringify-date-demo.md ================================================ ```ts import {maskitoParseDate, maskitoStringifyDate, MaskitoDateParams} from '@maskito/kit'; const params: MaskitoDateParams = { mode: 'dd/mm/yyyy', separator: '/', // default is '.' }; maskitoParseDate('05/02/2004', params); // returns Date object maskitoStringifyDate(new Date('2004-02-05'), params); // '05/02/2004' ``` ================================================ FILE: projects/demo/src/pages/kit/date-range/date-range-mask-doc.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormControl, ReactiveFormsModule} from '@angular/forms'; import {DocExamplePrimaryTab} from '@demo/constants'; import {MaskitoDirective} from '@maskito/angular'; import type {MaskitoOptions} from '@maskito/core'; import { type MaskitoDateMode, maskitoDateRangeOptionsGenerator, type MaskitoDateSegments, } from '@maskito/kit'; import {TuiAddonDoc, type TuiRawLoaderContent} from '@taiga-ui/addon-doc'; import {tuiPure} from '@taiga-ui/cdk'; import {TuiLink} from '@taiga-ui/core'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import {DateRangeMaskDocExample1} from './examples/1-date-localization/component'; import {DateRangeMaskDocExample2} from './examples/2-min-max/component'; import {DateRangeMaskDocExample3} from './examples/3-min-max-length/component'; import {DateRangeMaskDocExample4} from './examples/4-range-separator/component'; type GeneratorOptions = Required< NonNullable[0]> >; @Component({ selector: 'date-range-mask-doc', imports: [ DateRangeMaskDocExample1, DateRangeMaskDocExample2, DateRangeMaskDocExample3, DateRangeMaskDocExample4, MaskitoDirective, ReactiveFormsModule, TuiAddonDoc, TuiInputModule, TuiLink, TuiTextfieldControllerModule, ], templateUrl: './date-range-mask-doc.template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class DateRangeMaskDocComponent implements GeneratorOptions { protected readonly dateLocalizationExample1: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/1-date-localization/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly minMaxExample2: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/2-min-max/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly minMaxLengthExample3: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/3-min-max-length/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly customRangeExample4: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/4-range-separator/mask.ts?raw', {with: {loader: 'text'}} ), }; protected apiPageControl = new FormControl(''); protected readonly modeOptions = [ 'dd/mm/yyyy', 'mm/dd/yyyy', 'yyyy/mm/dd', 'mm/yy', 'mm/yyyy', 'yyyy/mm', 'yyyy', ] as const satisfies readonly MaskitoDateMode[]; protected readonly minMaxOptions = [ '0001-01-01', '9999-12-31', '2000-01-01', '2025-05-10', ] as const; protected readonly minLengthOptions: Array>> = [ {day: 3}, {day: 15}, {month: 1}, {month: 1, day: 1}, ]; protected readonly maxLengthOptions: Array>> = [ {day: 5}, {month: 1}, {year: 1}, ]; protected minStr: string = this.minMaxOptions[0]; protected maxStr: string = this.minMaxOptions[1]; public mode: MaskitoDateMode = this.modeOptions[0]; public min = new Date(this.minStr); public max = new Date(this.maxStr); public minLength: Partial> = {}; public maxLength: Partial> = {}; public dateSeparator = '.'; public rangeSeparator = ' – '; public maskitoOptions: MaskitoOptions = maskitoDateRangeOptionsGenerator(this); @tuiPure protected getPlaceholder( mode: MaskitoDateMode, dateSeparator: string, rangeSeparator: string, ): string { const datePlaceholder = mode.replaceAll('/', dateSeparator); return `${datePlaceholder}${rangeSeparator}${datePlaceholder}`; } protected updateOptions(): void { this.maskitoOptions = maskitoDateRangeOptionsGenerator(this); } protected updateDate(): void { this.min = new Date(this.minStr); this.max = new Date(this.maxStr); this.updateOptions(); } } ================================================ FILE: projects/demo/src/pages/kit/date-range/date-range-mask-doc.template.html ================================================ Use maskitoDateRangeOptionsGenerator to create a mask to input a range of dates. Use mode and dateSeparator parameters to get a mask with a locale specific representation of dates. Parameters min and max allow you to set the earliest and the latest available dates. They accept native Date . Use minLength and maxLength parameters to set minimal and maximal length of the date range. Use rangeSeparator parameter to customize separator between dates of the date range. Enter dates Date format mode Separator between date segments (days, months and years).

Default: . (dot).

Separator between dates of the date range.

Default:  – 

Earliest date Latest date Minimal length of the range Maximal length of the range
================================================ FILE: projects/demo/src/pages/kit/date-range/examples/1-date-localization/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiHint} from '@taiga-ui/core'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'date-range-mask-doc-example-1', imports: [ FormsModule, MaskitoDirective, TuiHint, TuiInputModule, TuiTextfieldControllerModule, ], template: ` US format `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class DateRangeMaskDocExample1 { private readonly usDateFormatter = new Intl.DateTimeFormat('en-US', { month: 'long', day: 'numeric', year: 'numeric', }); protected value = '09/20/2020 – 02/06/2023'; protected readonly filler = 'mm/dd/yyyy – mm/dd/yyyy'; protected readonly mask = mask; protected get hint(): string { return this.value.length < this.filler.length ? 'Complete the date range!' : this.value .split(' – ') .map((date) => this.usDateFormatter.format(new Date(date))) .join(' – '); } } ================================================ FILE: projects/demo/src/pages/kit/date-range/examples/1-date-localization/mask.ts ================================================ import {maskitoDateRangeOptionsGenerator} from '@maskito/kit'; export default maskitoDateRangeOptionsGenerator({ mode: 'mm/dd/yyyy', dateSeparator: '/', }); ================================================ FILE: projects/demo/src/pages/kit/date-range/examples/2-min-max/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'date-range-mask-doc-example-2', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class DateRangeMaskDocExample2 { protected value = '19.11.1711 – 15.04.1765'; protected readonly filler = 'dd.mm.yyyy – dd.mm.yyyy'; protected readonly mask = mask; } ================================================ FILE: projects/demo/src/pages/kit/date-range/examples/2-min-max/mask.ts ================================================ import {maskitoDateRangeOptionsGenerator} from '@maskito/kit'; export default maskitoDateRangeOptionsGenerator({ mode: 'dd/mm/yyyy', min: new Date('1711-11-19'), max: new Date('1765-04-15'), }); ================================================ FILE: projects/demo/src/pages/kit/date-range/examples/3-min-max-length/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiHint} from '@taiga-ui/core'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'date-range-mask-doc-example-3', imports: [ FormsModule, MaskitoDirective, TuiHint, TuiInputModule, TuiTextfieldControllerModule, ], template: ` `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class DateRangeMaskDocExample3 { protected value = '01.01.2023 – 05.01.2023'; protected readonly filler = 'dd.mm.yyyy – dd.mm.yyyy'; protected readonly mask = mask; protected readonly hint = 'The right date must be at least 3 days after the left one.\nAlso, the difference between the dates must not exceed 1 month.'; } ================================================ FILE: projects/demo/src/pages/kit/date-range/examples/3-min-max-length/mask.ts ================================================ import {maskitoDateRangeOptionsGenerator} from '@maskito/kit'; export default maskitoDateRangeOptionsGenerator({ mode: 'dd/mm/yyyy', minLength: {day: 3}, maxLength: {month: 1}, }); ================================================ FILE: projects/demo/src/pages/kit/date-range/examples/4-range-separator/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'date-range-mask-doc-example-4', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class DateRangeMaskDocExample4 { protected value = '01.01.2023 ~ 05.01.2023'; protected readonly filler = 'dd.mm.yyyy ~ dd.mm.yyyy'; protected readonly mask = mask; } ================================================ FILE: projects/demo/src/pages/kit/date-range/examples/4-range-separator/mask.ts ================================================ import {maskitoDateRangeOptionsGenerator} from '@maskito/kit'; export default maskitoDateRangeOptionsGenerator({ mode: 'dd/mm/yyyy', rangeSeparator: ' ~ ', }); ================================================ FILE: projects/demo/src/pages/kit/date-time/date-time-mask-doc.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormControl, ReactiveFormsModule} from '@angular/forms'; import {DocExamplePrimaryTab} from '@demo/constants'; import {MaskitoDirective} from '@maskito/angular'; import type {MaskitoOptions} from '@maskito/core'; import { type MaskitoDateMode, maskitoDateTimeOptionsGenerator, type MaskitoDateTimeParams, type MaskitoTimeMode, } from '@maskito/kit'; import {TuiAddonDoc, type TuiRawLoaderContent} from '@taiga-ui/addon-doc'; import {tuiPure} from '@taiga-ui/cdk'; import {TuiLink, TuiNotification} from '@taiga-ui/core'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import {DateTimeMaskDocExample1} from './examples/1-date-time-localization/component'; import {DateTimeMaskDocExample2} from './examples/2-date-time-separator/component'; import {DateTimeMaskDocExample3} from './examples/3-min-max/component'; import {DateTimeMaskDocExample4} from './examples/4-time-step/component'; import {DateTimeMaskDocExample5} from './examples/5-am-pm/component'; @Component({ selector: 'date-time-mask-doc', imports: [ DateTimeMaskDocExample1, DateTimeMaskDocExample2, DateTimeMaskDocExample3, DateTimeMaskDocExample4, DateTimeMaskDocExample5, MaskitoDirective, ReactiveFormsModule, TuiAddonDoc, TuiInputModule, TuiLink, TuiNotification, TuiTextfieldControllerModule, ], templateUrl: './date-time-mask-doc.template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class DateTimeMaskDocComponent implements Required { protected readonly maskitoParseStringifyDateTimeDemo = import('./examples/maskito-parse-stringify-date-time-demo.md'); protected readonly dateTimeLocalizationExample: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/1-date-time-localization/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly dateTimeSeparatorExample: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/2-date-time-separator/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly dateTimeMinMaxExample: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/3-min-max/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly dateTimeTimeStepExample: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/4-time-step/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly amPmExample: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/5-am-pm/mask.ts?raw', { with: {loader: 'text'}, }), }; protected apiPageControl = new FormControl(''); protected readonly dateModeOptions = [ 'dd/mm/yyyy', 'mm/dd/yyyy', 'yyyy/mm/dd', ] as const satisfies readonly MaskitoDateMode[]; protected readonly timeModeOptions = [ 'HH:MM', 'HH:MM AA', 'HH:MM:SS', 'HH:MM:SS AA', 'HH:MM:SS.MSS', 'HH:MM:SS.MSS AA', ] as const satisfies readonly MaskitoTimeMode[]; protected readonly minMaxOptions = [ '0001-01-01T00:00:00', '9999-12-31T23:59:59', '2000-01-01T12:30', '2025-05-10T18:30', ] as const; protected minStr: string = this.minMaxOptions[0]; protected maxStr: string = this.minMaxOptions[1]; public dateMode: MaskitoDateMode = this.dateModeOptions[0]; public timeMode: MaskitoTimeMode = this.timeModeOptions[0]; public dateTimeSeparator = ', '; public dateSeparator = '.'; public min = new Date(this.minStr); public max = new Date(this.maxStr); public timeStep = 0; public maskitoOptions: MaskitoOptions = maskitoDateTimeOptionsGenerator(this); @tuiPure protected getPlaceholder( dateMode: MaskitoDateMode, timeMode: MaskitoTimeMode, separator: string, dateTimeSeparator: string, ): string { return `${dateMode.replaceAll('/', separator)}${dateTimeSeparator}${timeMode}`; } protected updateOptions(): void { this.maskitoOptions = maskitoDateTimeOptionsGenerator(this); } protected updateDate(): void { this.min = new Date(this.minStr); this.max = new Date(this.maxStr); this.updateOptions(); } } ================================================ FILE: projects/demo/src/pages/kit/date-time/date-time-mask-doc.template.html ================================================ Use maskitoDateTimeOptionsGenerator to create a mask to input both date and time.
Despite the name of the mask, element's raw value is still string.

Use maskitoParseDateTime to get date from masked string.

Use maskitoStringifyDateTime to get the masked string from date.

Use dateMode , timeMode and dateSeparator parameters to get a mask with a locale specific representation of dates. Use dateTimeSeparator parameter to configure separator between date and time strings. Parameters min and max allow to set the earliest and the latest available dates. They accept native Date .

Property timeStep allows you to increment / decrement time segments by pressing ArrowUp / ArrowDown .

Use step === 0 (default value) to disable this feature.

Any timeMode ending with AA is 12-hour time format with meridiem part.
Enter date and time Date format mode Time format mode Date separator

Default: . (dot).

Separator between date and time

Default: (comma and space)

The value by which the keyboard arrows increment/decrement time segments

Default: 0 (disable stepping)

Earliest date Latest date
================================================ FILE: projects/demo/src/pages/kit/date-time/examples/1-date-time-localization/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'date-time-mask-doc-example-1', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` Localization `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class DateTimeMaskDocExample1 { protected value = '09/20/2020, 15:30'; protected readonly filler = 'mm/dd/yyyy, hh:mm'; protected readonly mask = mask; } ================================================ FILE: projects/demo/src/pages/kit/date-time/examples/1-date-time-localization/mask.ts ================================================ import {maskitoDateTimeOptionsGenerator} from '@maskito/kit'; export default maskitoDateTimeOptionsGenerator({ dateMode: 'mm/dd/yyyy', timeMode: 'HH:MM', dateSeparator: '/', }); ================================================ FILE: projects/demo/src/pages/kit/date-time/examples/2-date-time-separator/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'date-time-mask-doc-example-2', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` Custom date and time separator `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class DateTimeMaskDocExample2 { protected value = '05.02.2004; 10:10'; protected readonly filler = 'dd.mm.yyyy; hh:mm'; protected readonly mask = mask; } ================================================ FILE: projects/demo/src/pages/kit/date-time/examples/2-date-time-separator/mask.ts ================================================ import {maskitoDateTimeOptionsGenerator} from '@maskito/kit'; export default maskitoDateTimeOptionsGenerator({ dateMode: 'dd/mm/yyyy', timeMode: 'HH:MM', dateTimeSeparator: '; ', }); ================================================ FILE: projects/demo/src/pages/kit/date-time/examples/3-min-max/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'date-time-mask-doc-example-3', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` Min-max `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class DateTimeMaskDocExample3 { protected value = '09-01-2018, 15:30'; protected readonly filler = 'dd-mm-yyyy, hh:mm'; protected readonly mask = mask; } ================================================ FILE: projects/demo/src/pages/kit/date-time/examples/3-min-max/mask.ts ================================================ import {maskitoDateTimeOptionsGenerator} from '@maskito/kit'; export default maskitoDateTimeOptionsGenerator({ dateMode: 'dd/mm/yyyy', timeMode: 'HH:MM', dateSeparator: '-', min: new Date(2010, 1, 15, 12, 30, 0), max: new Date(2020, 8, 15, 18, 30, 0), }); ================================================ FILE: projects/demo/src/pages/kit/date-time/examples/4-time-step/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'date-time-mask-doc-example-4', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` Time Stepping `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class DateTimeMaskDocExample4 { protected value = '09.01.2018, 15:30'; protected readonly filler = 'dd.mm.yyyy, hh:mm'; protected readonly mask = mask; } ================================================ FILE: projects/demo/src/pages/kit/date-time/examples/4-time-step/mask.ts ================================================ import {maskitoDateTimeOptionsGenerator} from '@maskito/kit'; export default maskitoDateTimeOptionsGenerator({ dateMode: 'dd/mm/yyyy', timeMode: 'HH:MM', timeStep: 1, }); ================================================ FILE: projects/demo/src/pages/kit/date-time/examples/5-am-pm/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'date-time-mask-doc-example-5', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` With 12-hour time format `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class DateTimeMaskDocExample5 { protected value = '20/09/2020, 03:30 PM'; protected readonly filler = 'mm/dd/yyyy, hh:mm aa'; protected readonly mask = mask; } ================================================ FILE: projects/demo/src/pages/kit/date-time/examples/5-am-pm/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import { type MaskitoDateMode, maskitoDateTimeOptionsGenerator, maskitoSelectionChangeHandler, type MaskitoTimeMode, } from '@maskito/kit'; const dateTimeSeparator = ', '; const dateMode: MaskitoDateMode = 'dd/mm/yyyy'; const timeMode: MaskitoTimeMode = 'HH:MM AA'; const dateTimeOptions = maskitoDateTimeOptionsGenerator({ dateMode, timeMode, dateTimeSeparator, dateSeparator: '/', }); export default { ...dateTimeOptions, plugins: [ ...dateTimeOptions.plugins, maskitoSelectionChangeHandler((element) => { element.inputMode = element.selectionStart! >= `${dateMode}${dateTimeSeparator}HH:MM`.length ? 'text' : 'numeric'; }), ], } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/kit/date-time/examples/maskito-parse-stringify-date-time-demo.md ================================================ ```ts import {maskitoParseDateTime, maskitoStringifyDateTime, MaskitoDateTimeParams} from '@maskito/kit'; const params: MaskitoDateTimeParams = { dateMode: 'dd/mm/yyyy', timeMode: 'HH:MM', dateSeparator: ', ', }; maskitoParseDateTime('07.11.2022, 13:17', params); // returns Date object maskitoStringifyDateTime(new Date(2022, 10, 7, 13, 17), params); // '07.11.2022, 13:17' ``` ================================================ FILE: projects/demo/src/pages/kit/number/examples/1-high-precision/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'number-mask-doc-example-1', imports: [FormsModule, MaskitoDirective, TuiInputModule], template: ` π -value `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class NumberMaskDocExample1 { protected value = ''; protected maskitoOptions = mask; } ================================================ FILE: projects/demo/src/pages/kit/number/examples/1-high-precision/mask.ts ================================================ import {maskitoNumberOptionsGenerator} from '@maskito/kit'; export default maskitoNumberOptionsGenerator({ maximumFractionDigits: 8, min: 0, }); ================================================ FILE: projects/demo/src/pages/kit/number/examples/2-separators/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'number-mask-doc-example-2', imports: [FormsModule, MaskitoDirective, TuiInputModule], template: ` Type number like a German `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class NumberMaskDocExample2 { protected maskitoOptions = mask; protected value = ''; } ================================================ FILE: projects/demo/src/pages/kit/number/examples/2-separators/mask.ts ================================================ import {maskitoNumberOptionsGenerator} from '@maskito/kit'; export default maskitoNumberOptionsGenerator({ decimalSeparator: ',', thousandSeparator: '.', maximumFractionDigits: 2, }); ================================================ FILE: projects/demo/src/pages/kit/number/examples/3-postfix/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask, {postfix} from './mask'; @Component({ selector: 'number-mask-doc-example-3', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class NumberMaskDocExample3 { protected value = `97${postfix}`; protected readonly maskitoOptions = mask; } ================================================ FILE: projects/demo/src/pages/kit/number/examples/3-postfix/mask.ts ================================================ import {type MaskitoOptions, maskitoUpdateElement} from '@maskito/core'; import { maskitoCaretGuard, maskitoEventHandler, maskitoNumberOptionsGenerator, } from '@maskito/kit'; export const postfix = '%'; const {plugins, ...numberOptions} = maskitoNumberOptionsGenerator({ postfix, min: 0, max: 100, maximumFractionDigits: 2, }); export default { ...numberOptions, plugins: [ ...plugins, // Forbids caret to be placed after postfix maskitoCaretGuard((value) => [0, value.length - 1]), maskitoEventHandler('blur', (element) => { if (element.value === postfix) { maskitoUpdateElement(element, `0${postfix}`); } }), ], } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/kit/number/examples/4-decimal-zero-padding/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'number-mask-doc-example-4', imports: [FormsModule, MaskitoDirective, TuiInputModule], template: ` Cost `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class NumberMaskDocExample4 { protected value = '$100.00'; protected maskitoOptions = mask; } ================================================ FILE: projects/demo/src/pages/kit/number/examples/4-decimal-zero-padding/mask.ts ================================================ import {maskitoNumberOptionsGenerator} from '@maskito/kit'; export default maskitoNumberOptionsGenerator({ minimumFractionDigits: 2, maximumFractionDigits: 2, decimalSeparator: '.', min: 0, prefix: '$', }); ================================================ FILE: projects/demo/src/pages/kit/number/examples/5-custom-minus-sign/components.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'number-mask-doc-example-5', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class NumberMaskDocExample5 { protected value = '-42'; protected readonly options = mask; } ================================================ FILE: projects/demo/src/pages/kit/number/examples/5-custom-minus-sign/mask.ts ================================================ import {maskitoNumberOptionsGenerator} from '@maskito/kit'; export default maskitoNumberOptionsGenerator({ minusSign: '-', thousandSeparator: '', }); ================================================ FILE: projects/demo/src/pages/kit/number/examples/6-minus-before-prefix/components.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'number-mask-doc-example-6', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class NumberMaskDocExample6 { protected value = '-$777'; protected readonly options = mask; } ================================================ FILE: projects/demo/src/pages/kit/number/examples/6-minus-before-prefix/mask.ts ================================================ import {maskitoNumberOptionsGenerator} from '@maskito/kit'; export default maskitoNumberOptionsGenerator({ minusSign: '-', prefix: '$', negativePattern: 'minusFirst', }); ================================================ FILE: projects/demo/src/pages/kit/number/examples/7-dynamic-decimal-zero-padding/component.ts ================================================ /** * This example demonstrates Angular way. * But this behaviour can be achieved via vanilla JavaScript too * (it just requires more code). */ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import type {MaskitoOptions} from '@maskito/core'; import {tuiPure} from '@taiga-ui/cdk'; import {TuiLabel} from '@taiga-ui/core'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import {getMaskitoOptions} from './mask'; @Component({ selector: 'number-mask-doc-example-7', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiLabel, TuiTextfieldControllerModule, ], template: ` `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class NumberMaskDocExample7 { protected value = '42'; protected decimalZeroPadding = this.value.includes('.'); @tuiPure // Decorator for memoization protected getMaskOptions(decimalZeroPadding: boolean): MaskitoOptions { return getMaskitoOptions(decimalZeroPadding); } protected handleBeforeInput(event: Event): void { const {inputType, target, data} = event as InputEvent; if (inputType.includes('delete')) { const element = target as HTMLInputElement; const [from, to] = this.getNotEmptySelection( [element.selectionStart ?? 0, element.selectionEnd ?? 0], inputType.includes('Forward'), ); const dotWasRemoved = this.value.slice(from, to).includes('.'); this.decimalZeroPadding = this.decimalZeroPadding && !dotWasRemoved; } else { this.decimalZeroPadding = ['.', ',', 'б', 'ю'].some( (sep) => data?.includes(sep) || this.value.includes(sep), ); } } private getNotEmptySelection( [from, to]: [number, number], isForward: boolean, ): [number, number] { if (from !== to) { return [from, to]; } return isForward ? [from, to + 1] : [Math.max(from - 1, 0), to]; } } ================================================ FILE: projects/demo/src/pages/kit/number/examples/7-dynamic-decimal-zero-padding/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import {maskitoNumberOptionsGenerator} from '@maskito/kit'; export function getMaskitoOptions(decimalZeroPadding: boolean): MaskitoOptions { return maskitoNumberOptionsGenerator({ minimumFractionDigits: decimalZeroPadding ? 2 : 0, maximumFractionDigits: 2, decimalSeparator: '.', min: 0, }); } ================================================ FILE: projects/demo/src/pages/kit/number/examples/8-thousand-separator-pattern/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'number-mask-doc-example-8', imports: [FormsModule, MaskitoDirective, TuiInputModule], template: ` Japanese yen `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class NumberMaskDocExample8 { protected maskitoOptions = mask; protected value = ''; } ================================================ FILE: projects/demo/src/pages/kit/number/examples/8-thousand-separator-pattern/mask.ts ================================================ import {maskitoNumberOptionsGenerator, type MaskitoNumberParams} from '@maskito/kit'; const japaneseYenGrouping: MaskitoNumberParams['thousandSeparatorPattern'] = (digits) => digits.match(/\d{1,4}(?=(?:\d{4})*$)/g) ?? []; export default maskitoNumberOptionsGenerator({ prefix: '¥', thousandSeparator: ',', thousandSeparatorPattern: japaneseYenGrouping, }); ================================================ FILE: projects/demo/src/pages/kit/number/examples/9-thousand-separator-pattern-intl/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'number-mask-doc-example-9', imports: [FormsModule, MaskitoDirective, TuiInputModule], template: ` Indian numbering system `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class NumberMaskDocExample9 { protected maskitoOptions = mask; protected value = ''; } ================================================ FILE: projects/demo/src/pages/kit/number/examples/9-thousand-separator-pattern-intl/mask.ts ================================================ import {maskitoNumberOptionsGenerator, type MaskitoNumberParams} from '@maskito/kit'; export const intlPattern = ( locale: string, ): MaskitoNumberParams['thousandSeparatorPattern'] => { const formatter = new Intl.NumberFormat(locale, {maximumFractionDigits: 0}); return (digits: string): readonly string[] => { if (!digits) { return []; } let pos = 0; return formatter .formatToParts(BigInt(`1${'0'.repeat(digits.length - 1)}`)) .filter((part) => part.type === 'integer') .map((part) => { const group = digits.slice(pos, pos + part.value.length); pos += part.value.length; return group; }); }; }; export default maskitoNumberOptionsGenerator({ prefix: '₹', thousandSeparator: ',', thousandSeparatorPattern: intlPattern('en-IN'), }); ================================================ FILE: projects/demo/src/pages/kit/number/helpers/parse-number-as-bigint-type.md ================================================ ```ts import { maskitoParseNumber, type MaskitoNumberParams, // type for 2nd argument of maskitoParseNumber maskitoNumberOptionsGenerator, } from '@maskito/kit'; const params: MaskitoNumberParams = { thousandSeparator: '_', }; maskitoNumberOptionsGenerator(params); // MaskitoOptions const value: bigint | null = maskitoParseNumber('1_234_567_890_123_456_789', { ...params, bigint: true, }); // 1234567890123456789n value > Number.MAX_SAFE_INTEGER; // true typeof value === 'bigint'; // true // "Empty" values maskitoParseNumber('', {bigint: true}); // null maskitoParseNumber('-', {bigint: true}); // null ``` ================================================ FILE: projects/demo/src/pages/kit/number/helpers/parse-number-as-number-type.md ================================================ ```ts import { maskitoParseNumber, type MaskitoNumberParams, // type for 2nd argument of maskitoParseNumber maskitoNumberOptionsGenerator, } from '@maskito/kit'; const params: MaskitoNumberParams = { decimalSeparator: ',', // default is '.' }; maskitoNumberOptionsGenerator(params); // MaskitoOptions const value: number = maskitoParseNumber('10 000,42', params); // 10000.42 typeof value === 'number'; // true // "Empty" values maskitoParseNumber(''); // NaN maskitoParseNumber('-'); // NaN ``` ================================================ FILE: projects/demo/src/pages/kit/number/helpers/parse-number-invalid-usage.md ================================================ ```ts maskitoParseNumber('-42'); // -42 ✅ maskitoParseNumber('> -42'); // 42 ❌ maskitoParseNumber('> -42', {prefix: '> '}); // -42 ✅ ``` ================================================ FILE: projects/demo/src/pages/kit/number/helpers/stringify-number.md ================================================ ```ts import { maskitoStringifyNumber, type MaskitoNumberParams, // type for 2nd argument of maskitoStringifyNumber maskitoNumberOptionsGenerator, } from '@maskito/kit'; const params: MaskitoNumberParams = { thousandSeparator: '_', prefix: '$', }; maskitoNumberOptionsGenerator(params); // MaskitoOptions maskitoStringifyNumber(null); // '' maskitoStringifyNumber(NaN); // '' maskitoStringifyNumber(1234, params); // '$1_234' maskitoStringifyNumber(BigInt('1234'), params); // '$1_234' ``` ================================================ FILE: projects/demo/src/pages/kit/number/number-mask-doc.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormControl, ReactiveFormsModule} from '@angular/forms'; import {DocExamplePrimaryTab} from '@demo/constants'; import {MaskitoDirective} from '@maskito/angular'; import type {MaskitoOptions} from '@maskito/core'; import { maskitoAddOnFocusPlugin, maskitoCaretGuard, maskitoNumberOptionsGenerator, type MaskitoNumberParams, maskitoRemoveOnBlurPlugin, } from '@maskito/kit'; import {CHAR_MINUS} from '@maskito/kit/src/lib/constants'; import {TuiAddonDoc, type TuiRawLoaderContent} from '@taiga-ui/addon-doc'; import {TuiLink, TuiNotification} from '@taiga-ui/core'; import {TuiInputModule} from '@taiga-ui/legacy'; import {NumberMaskDocExample1} from './examples/1-high-precision/component'; import {NumberMaskDocExample2} from './examples/2-separators/component'; import {NumberMaskDocExample3} from './examples/3-postfix/component'; import {NumberMaskDocExample4} from './examples/4-decimal-zero-padding/component'; import {NumberMaskDocExample5} from './examples/5-custom-minus-sign/components'; import {NumberMaskDocExample6} from './examples/6-minus-before-prefix/components'; import {NumberMaskDocExample7} from './examples/7-dynamic-decimal-zero-padding/component'; import {NumberMaskDocExample8} from './examples/8-thousand-separator-pattern/component'; import {NumberMaskDocExample9} from './examples/9-thousand-separator-pattern-intl/component'; type GeneratorParams = Omit< Required, 'minusPseudoSigns' | 'thousandSeparatorPattern' > & Pick; @Component({ selector: 'number-mask-doc', imports: [ MaskitoDirective, NumberMaskDocExample1, NumberMaskDocExample2, NumberMaskDocExample3, NumberMaskDocExample4, NumberMaskDocExample5, NumberMaskDocExample6, NumberMaskDocExample7, NumberMaskDocExample8, NumberMaskDocExample9, ReactiveFormsModule, TuiAddonDoc, TuiInputModule, TuiLink, TuiNotification, ], templateUrl: './number-mask-doc.template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class NumberMaskDocComponent implements GeneratorParams { protected readonly parseNumberAsNumberTypeDemo = import('./helpers/parse-number-as-number-type.md'); protected readonly parseNumberAsBigIntTypeDemo = import('./helpers/parse-number-as-bigint-type.md'); protected readonly parseNumberInvalidUsageDemo = import('./helpers/parse-number-invalid-usage.md'); protected readonly stringifyNumberDemo = import('./helpers/stringify-number.md'); protected readonly highPrecisionExample1: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/1-high-precision/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly separatorsExample2: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/2-separators/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly postfixExample3: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/3-postfix/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly decimalZeroPaddingExample4: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/4-decimal-zero-padding/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly customMinusSignExample5: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/5-custom-minus-sign/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly minusBeforePrefixExample6: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/6-minus-before-prefix/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly thousandSeparatorPatternExample8: Record< string, TuiRawLoaderContent > = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/8-thousand-separator-pattern/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly thousandSeparatorPatternIntlExample9: Record< string, TuiRawLoaderContent > = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/9-thousand-separator-pattern-intl/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly dynamicDecimalZeroPaddingExample7: Record< string, TuiRawLoaderContent > = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/7-dynamic-decimal-zero-padding/mask.ts?raw', {with: {loader: 'text'}} ), [DocExamplePrimaryTab.Angular]: import( './examples/7-dynamic-decimal-zero-padding/component.ts?raw', {with: {loader: 'text'}} ), }; protected apiPageControl = new FormControl(''); protected readonly decimalPseudoSeparatorsOptions = [ ['.', ',', 'б', 'ю'], ['.'], [','], ]; protected readonly maximumFractionDigitsOptions = [0, 1, 2, 5, 10, Infinity]; protected readonly negativePatternOptions = [ 'prefixFirst', 'minusFirst', ] as const satisfies ReadonlyArray['negativePattern']>; protected readonly minOptions: ReadonlyArray = [ -Infinity, BigInt(`-${'987654321'.repeat(3)}`), Number.MIN_SAFE_INTEGER, -123, -100, 0, 0.1, 5, ]; protected readonly maxOptions: ReadonlyArray = [ Infinity, BigInt('987654321'.repeat(3)), Number.MAX_SAFE_INTEGER, 777, 3, 0, -0.1, -5, ]; public max = Infinity; public min = -Infinity; public decimalSeparator = '.'; public decimalPseudoSeparators = this.decimalPseudoSeparatorsOptions[0]!; public thousandSeparator = ' '; public prefix = ''; public postfix = ''; public minusSign = CHAR_MINUS; public minimumFractionDigits = 0; public maximumFractionDigits = 0; public negativePattern: Required['negativePattern'] = this.negativePatternOptions[0]; public thousandSeparatorPattern: MaskitoNumberParams['thousandSeparatorPattern']; public maskitoOptions = this.calculateMask(this); protected updateOptions(): void { this.maskitoOptions = this.calculateMask(this); } private calculateMask(params: GeneratorParams): MaskitoOptions { const {prefix, postfix, negativePattern, minusSign} = params; const {plugins, ...numberOptions} = maskitoNumberOptionsGenerator(params); return { ...numberOptions, plugins: [ ...plugins, maskitoAddOnFocusPlugin(`${prefix}${postfix}`), maskitoRemoveOnBlurPlugin(`${prefix}${postfix}`), maskitoCaretGuard((value) => [ negativePattern === 'minusFirst' && value.includes(minusSign) ? minusSign.length + prefix.length : prefix.length, value.length - postfix.length, ]), ], }; } } ================================================ FILE: projects/demo/src/pages/kit/number/number-mask-doc.template.html ================================================ Use maskitoNumberOptionsGenerator to create a mask for entering a formatted number. Use maximumFractionDigits parameter to configure the number of digits after decimal separator. Use decimalSeparator and thousandSeparator to get mask with locale specific representation of numbers. In Germany people use comma as decimal separator and dot for thousands
Use postfix parameter to set non-removable text after the number.
Additionally you can use maskitoCaretGuard to clamp caret inside allowable range.
This example also shows how to restrict the greatest permitted value via max parameter.

Use minimumFractionDigits to always show trailing zeroes.

Non removable dollar sign is achieved by using prefix parameter.

Use minusSign parameter to configure the character which indicates that a number is negative.

In this example hyphen is used as minusSign

Use negativePattern property to configure order of prefix and minus sign (by default, prefix is always placed before minus).
You can change options on the fly to build complex logic.
This example shows how to initially disable decimal zero padding and enable it only after user inserts decimal separator.

Use thousandSeparatorPattern to customize digit grouping. Provide a function that receives raw integer digits as a string and returns them split into groups, left-to-right.

This example implements 4-digit grouping for Japanese yen — the traditional (10 000) counting system: ¥1,2345,6789.

Japanese numbering groups digits in sets of 4 from right: ¥1,2345,6789
Use Intl.NumberFormat.formatToParts to derive the grouping from a browser locale automatically. Pass the result to thousandSeparatorPattern parameter. Indian numbering system groups digits as 2+3 from right: 12,34,567
Enter a number Symbol for separating fraction.

Default: point.

Symbols to be replaced with decimalSeparator .

Default: ['.', 'ю', 'б'] .

Symbol for separating thousands.

Default: non-breaking space.

A function that defines how integer digits are split into groups. Receives raw integer digits as a string; returns them as an ordered array of groups (left-to-right).

Default: standard 3-digit Western grouping driven by thousandSeparator .

The minimum number of fraction digits to use.

A value with a smaller number of fraction digits than this number will be right-padded with zeros (to the specified length).

Default: 0 .

The maximum number of digits after decimalSeparator .

Use Infinity for an untouched decimal part.

Default: 0 (decimal part is forbidden).

The lowest permitted value.

Default: -Infinity .

The greatest permitted value.

Default: Infinity .

A prefix symbol, like currency.

Default: empty string (no prefix).

A postfix symbol, like currency.

Default: empty string (no postfix).

A minus symbol.

Default: \u2212

Order of prefix and minus sign

Default: 'prefixFirst'

Despite the name of the mask, the element's raw value remains of type string !

Use the helpers below to perform stringnumber conversions seamlessly.

maskitoParseNumber

  • By default, returns number type value
  • Also, supports bigint mode
Always pass the second argument to the utility if your number format parameters differ from the default ones to ensure correct parsing and avoid unexpected results!

maskitoStringifyNumber

Works with both number and bigint types.
================================================ FILE: projects/demo/src/pages/kit/plugins/examples/1-selection-handler/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiIcon, TuiTextfield} from '@taiga-ui/core'; import mask from './mask'; @Component({ selector: 'kit-plugins-doc-example-1', imports: [FormsModule, MaskitoDirective, TuiIcon, TuiTextfield], template: ` `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class KitPluginsDocExample1 { protected value = '05:00 PM'; protected maskitoOptions = mask; } ================================================ FILE: projects/demo/src/pages/kit/plugins/examples/1-selection-handler/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import { maskitoSelectionChangeHandler, type MaskitoTimeMode, maskitoTimeOptionsGenerator, } from '@maskito/kit'; const mode: MaskitoTimeMode = 'HH:MM AA'; const timeOptions = maskitoTimeOptionsGenerator({mode}); export default { ...timeOptions, plugins: [ ...timeOptions.plugins, maskitoSelectionChangeHandler((element) => { element.inputMode = element.selectionStart! >= mode.indexOf(' AA') ? 'text' : 'numeric'; }), ], } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/kit/plugins/examples/2-caret-guard/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiTextfield} from '@taiga-ui/core'; import mask from './mask'; @Component({ selector: 'kit-plugins-doc-example-2', imports: [FormsModule, MaskitoDirective, TuiTextfield], template: ` `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class KitPluginsDocExample2 { protected value = '$100 per day'; protected maskitoOptions = mask; } ================================================ FILE: projects/demo/src/pages/kit/plugins/examples/2-caret-guard/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import {maskitoCaretGuard, maskitoNumberOptionsGenerator} from '@maskito/kit'; const prefix = '$'; const postfix = ' per day'; const numberOptions = maskitoNumberOptionsGenerator({ prefix, postfix, min: 0, }); export default { ...numberOptions, plugins: [ ...numberOptions.plugins, maskitoCaretGuard((value) => [prefix.length, value.length - postfix.length]), ], } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/kit/plugins/examples/3-event-handlers/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiIcon, TuiTextfield} from '@taiga-ui/core'; import mask from './mask'; @Component({ selector: 'kit-plugins-doc-example-3', imports: [FormsModule, MaskitoDirective, TuiIcon, TuiTextfield], template: ` `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class KitPluginsDocExample3 { protected value = ''; protected maskitoOptions = mask; } ================================================ FILE: projects/demo/src/pages/kit/plugins/examples/3-event-handlers/mask.ts ================================================ import {type MaskitoOptions, maskitoUpdateElement} from '@maskito/core'; import { maskitoAddOnFocusPlugin, maskitoEventHandler, maskitoPrefixPostprocessorGenerator, } from '@maskito/kit'; // import {maskitoRemoveOnBlurPlugin} from '@maskito/kit'; const countryPrefix = '+1 '; export default { plugins: [ maskitoAddOnFocusPlugin(countryPrefix), /** * You can also just use `maskitoRemoveOnBlurPlugin(countryPrefix)` * instead of plugin below. */ maskitoEventHandler('blur', (element) => { if (element.value === countryPrefix) { maskitoUpdateElement(element, ''); } }), ], postprocessors: [maskitoPrefixPostprocessorGenerator(countryPrefix)], mask: [ '+', '1', ' ', '(', /\d/, /\d/, /\d/, ')', ' ', /\d/, /\d/, /\d/, '-', /\d/, /\d/, /\d/, /\d/, ], } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/kit/plugins/examples/4-reject/animation.css ================================================ :root { --red: 244, 87, 37; } @keyframes reject-0 { from { box-shadow: 0 0 rgba(var(--red), 1); } to { box-shadow: 0 0 1rem rgba(var(--red), 0.12); } } @keyframes reject-1 { from { box-shadow: 0 0 rgba(var(--red), 1); } to { box-shadow: 0 0 1rem rgba(var(--red), 0.12); } } ================================================ FILE: projects/demo/src/pages/kit/plugins/examples/4-reject/component.ts ================================================ import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'kit-plugins-doc-example-4', imports: [FormsModule, MaskitoDirective, TuiInputModule], template: ` CVC `, styleUrl: './animation.css', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) export class KitPluginsDocExample4 { protected readonly maskitoOptions = mask; protected value = ''; } ================================================ FILE: projects/demo/src/pages/kit/plugins/examples/4-reject/index.md ================================================ ```ts import './animation.css'; import {Maskito} from '@maskito/core'; import maskitoOptions from './mask'; const element = document.querySelector('input')!; const maskedInput = new Maskito(element, maskitoOptions); console.info('Call this function when the element is detached from DOM', maskedInput.destroy); ``` ================================================ FILE: projects/demo/src/pages/kit/plugins/examples/4-reject/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import {maskitoRejectEvent} from '@maskito/kit'; const maskitoOptions: MaskitoOptions = { mask: /^\d{0,3}$/, plugins: [ maskitoRejectEvent, (element) => { element.style.animation = '0.3s 1'; let reject = -1; const listener = (): void => { reject += 1; element.style.animationName = `reject-${reject % 2}`; }; element.addEventListener('maskitoReject', listener); return () => element.removeEventListener('maskitoReject', listener); }, ], }; export default maskitoOptions; ================================================ FILE: projects/demo/src/pages/kit/plugins/kit-plugins-doc.component.ts ================================================ import {ChangeDetectionStrategy, Component, inject} from '@angular/core'; import {RouterLink} from '@angular/router'; import {DemoPath, DocExamplePrimaryTab} from '@demo/constants'; import {TuiAddonDoc, type TuiRawLoaderContent} from '@taiga-ui/addon-doc'; import {TUI_IS_MOBILE} from '@taiga-ui/cdk'; import {TuiLink, TuiNotification} from '@taiga-ui/core'; import {KitPluginsDocExample1} from './examples/1-selection-handler/component'; import {KitPluginsDocExample2} from './examples/2-caret-guard/component'; import {KitPluginsDocExample3} from './examples/3-event-handlers/component'; import {KitPluginsDocExample4} from './examples/4-reject/component'; @Component({ selector: 'kit-plugins-doc', imports: [ KitPluginsDocExample1, KitPluginsDocExample2, KitPluginsDocExample3, KitPluginsDocExample4, RouterLink, TuiAddonDoc, TuiLink, TuiNotification, ], templateUrl: './kit-plugins-doc.template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class KitPluginsDocComponent { protected readonly pluginsDocPage = `/${DemoPath.Plugins}`; protected readonly isMobile = inject(TUI_IS_MOBILE); protected readonly selectionChangeHandlerExample: Record< string, TuiRawLoaderContent > = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/1-selection-handler/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly caretGuardExample: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/2-caret-guard/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly eventHandlersExample: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/3-event-handlers/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly rejectExample: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/4-reject/mask.ts?raw', { with: {loader: 'text'}, }), 'animation.css': import('./examples/4-reject/animation.css'), [DocExamplePrimaryTab.JavaScript]: import('./examples/4-reject/index.md'), }; } ================================================ FILE: projects/demo/src/pages/kit/plugins/kit-plugins-doc.template.html ================================================ The page contains list of officially supported plugins by Maskito team. If you wish to develop your own plugin, read documentation page about plugins . Plugin maskitoSelectionChangeHandler accepts callback and invokes it on every change of caret position.

This examples demonstrates how dynamically switch native mobile keyboard to enter different parts of time string: numeric - to enter digit time segments, text – to enter meridiem part (AM / PM).

@if (!isMobile) { Use real mobile device to see how it works! }
Plugin maskitoCaretGuard is specific instance of maskitoSelectionChangeHandler - it also accepts callback which is triggered on every caret position change. It is used to limit the boundaries for caret position.

Callback should return array with 2 numbers: the first one – caret cannot be placed before this index, the last one – caret cannot be placed after this index.

It can be especially useful for textfields with non-editable affixes.

Add/remove non-editable prefix on focus/blur is so common task that we even created maskitoAddOnFocusPlugin / maskitoRemoveOnBlurPlugin plugins.

If you need more complex logic for these (or other) events – use maskitoEventHandler .

Plugin maskitoRejectEvent dispatches custom event maskitoReject when a character that the user has entered is rejected by the mask. You can use it to visualize rejection.
================================================ FILE: projects/demo/src/pages/kit/time/examples/1-modes/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'time-mask-doc-example-1', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` Enter 24-hour time format `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class TimeMaskDocExample1 { protected readonly mask = mask; protected value = '23:59:59'; } ================================================ FILE: projects/demo/src/pages/kit/time/examples/1-modes/mask.ts ================================================ import {maskitoTimeOptionsGenerator} from '@maskito/kit'; export default maskitoTimeOptionsGenerator({mode: 'HH:MM:SS'}); ================================================ FILE: projects/demo/src/pages/kit/time/examples/2-am-pm/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'time-mask-doc-example-2', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` Enter 12-hour time format `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class TimeMaskDocExample2 { protected readonly mask = mask; protected value = '03:30 PM'; } ================================================ FILE: projects/demo/src/pages/kit/time/examples/2-am-pm/mask.ts ================================================ import {type MaskitoOptions, maskitoUpdateElement} from '@maskito/core'; import { maskitoEventHandler, maskitoSelectionChangeHandler, maskitoTimeOptionsGenerator, } from '@maskito/kit'; const timeOptions = maskitoTimeOptionsGenerator({mode: 'HH:MM AA'}); export default { ...timeOptions, plugins: [ ...timeOptions.plugins, maskitoSelectionChangeHandler((element) => { element.inputMode = element.selectionStart! >= 'HH:MM'.length ? 'text' : 'numeric'; }), maskitoEventHandler('blur', (element) => { if (element.value.length >= 'HH:MM'.length && !element.value.endsWith('M')) { maskitoUpdateElement(element, `${element.value} AM`); } }), ], } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/kit/time/examples/3-step/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'time-mask-doc-example-3', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class TimeMaskDocExample3 { protected value = '11:59:59'; protected readonly mask = mask; } ================================================ FILE: projects/demo/src/pages/kit/time/examples/3-step/mask.ts ================================================ import {type MaskitoOptions, maskitoUpdateElement} from '@maskito/core'; import {maskitoEventHandler, maskitoTimeOptionsGenerator} from '@maskito/kit'; const timeOptions = maskitoTimeOptionsGenerator({ mode: 'HH:MM:SS', step: 1, }); export default { ...timeOptions, plugins: [ ...timeOptions.plugins, maskitoEventHandler('blur', (element) => { const [hh = '', mm = '', ss = ''] = element.value.split(':'); maskitoUpdateElement( element, [hh, mm, ss].map((segment) => segment.padEnd(2, '0')).join(':'), ); }), ], } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/kit/time/examples/4-affixes/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'time-mask-doc-example-4', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` Timer (minutes) `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class TimeMaskDocExample4 { protected value = '05:00 left'; protected readonly maskitoOptions = mask; } ================================================ FILE: projects/demo/src/pages/kit/time/examples/4-affixes/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import { maskitoAddOnFocusPlugin, maskitoCaretGuard, maskitoRemoveOnBlurPlugin, maskitoTimeOptionsGenerator, } from '@maskito/kit'; export const postfix = ' left'; const {plugins, ...timeOptions} = maskitoTimeOptionsGenerator({ postfix, mode: 'MM:SS', }); export default { ...timeOptions, plugins: [ ...plugins, maskitoRemoveOnBlurPlugin(postfix), maskitoAddOnFocusPlugin(postfix), // Forbids caret to be placed after postfix maskitoCaretGuard((value) => [0, value.length - postfix.length]), ], } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/kit/time/examples/5-time-segments-min-max/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiTextfield} from '@taiga-ui/core'; import {TuiSegmented} from '@taiga-ui/kit'; import mask from './mask'; @Component({ selector: 'time-mask-doc-example-5', imports: [FormsModule, MaskitoDirective, TuiSegmented, TuiTextfield], template: ` `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class TimeMaskDocExample5 { protected value = '03:30'; protected readonly mask = mask; } ================================================ FILE: projects/demo/src/pages/kit/time/examples/5-time-segments-min-max/mask.ts ================================================ import {maskitoTimeOptionsGenerator} from '@maskito/kit'; export default maskitoTimeOptionsGenerator({ mode: 'HH:MM', timeSegmentMaxValues: {hours: 12}, timeSegmentMinValues: {hours: 1}, }); ================================================ FILE: projects/demo/src/pages/kit/time/examples/maskito-parse-stringify-time-demo.md ================================================ ```ts import {maskitoParseTime, maskitoStringifyTime, MaskitoTimeParams} from '@maskito/kit'; const params: MaskitoTimeParams = {mode: 'HH:MM:SS.MSS'}; maskitoParseTime('23:59:59.999', params); // 86399999 maskitoParseTime('12:3', params); // 43380000 (parsed like '12:30:00.000') maskitoStringifyTime(86399999, params); // '23:59:59.999' ``` ================================================ FILE: projects/demo/src/pages/kit/time/time-mask-doc.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormControl, ReactiveFormsModule} from '@angular/forms'; import {RouterLink} from '@angular/router'; import {DemoPath, DocExamplePrimaryTab} from '@demo/constants'; import {MaskitoDirective} from '@maskito/angular'; import type {MaskitoOptions} from '@maskito/core'; import { type MaskitoTimeMode, maskitoTimeOptionsGenerator, type MaskitoTimeParams, type MaskitoTimeSegments, } from '@maskito/kit'; import {TuiAddonDoc, type TuiRawLoaderContent} from '@taiga-ui/addon-doc'; import {TuiLink, TuiNotification} from '@taiga-ui/core'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import {TimeMaskDocExample1} from './examples/1-modes/component'; import {TimeMaskDocExample2} from './examples/2-am-pm/component'; import {TimeMaskDocExample3} from './examples/3-step/component'; import {TimeMaskDocExample4} from './examples/4-affixes/component'; import {TimeMaskDocExample5} from './examples/5-time-segments-min-max/component'; @Component({ selector: 'time-mask-doc', imports: [ MaskitoDirective, ReactiveFormsModule, RouterLink, TimeMaskDocExample1, TimeMaskDocExample2, TimeMaskDocExample3, TimeMaskDocExample4, TimeMaskDocExample5, TuiAddonDoc, TuiInputModule, TuiLink, TuiNotification, TuiTextfieldControllerModule, ], templateUrl: './time-mask-doc.template.html', styleUrl: './time-mask-doc.style.less', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class TimeMaskDocComponent implements Required { protected pages = DemoPath; protected readonly maskitoParseStringifyTimeDemo = import('./examples/maskito-parse-stringify-time-demo.md'); protected readonly modeExample1: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/1-modes/mask.ts?raw', { with: {loader: 'text'}, }), }; protected readonly amPmExample2: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/2-am-pm/mask.ts?raw', { with: {loader: 'text'}, }), }; protected readonly stepExample3: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/3-step/mask.ts?raw', { with: {loader: 'text'}, }), }; protected readonly affixesExample4: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/4-affixes/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly timeSegmentsMinMaxExample5: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/5-time-segments-min-max/mask.ts?raw', {with: {loader: 'text'}} ), }; protected apiPageControl = new FormControl(''); protected readonly modeOptions = [ 'HH:MM', 'HH:MM AA', 'HH:MM:SS', 'HH:MM:SS AA', 'HH:MM:SS.MSS', 'HH:MM:SS.MSS AA', 'HH', 'HH AA', 'MM:SS.MSS', 'SS.MSS', 'MM:SS', ] as const satisfies readonly MaskitoTimeMode[]; protected readonly timeSegmentMaxValuesOptions = [ {}, {hours: 23, minutes: 59, seconds: 59, milliseconds: 999}, {hours: 11}, {hours: 5, minutes: 5, seconds: 5, milliseconds: 5}, ] as const satisfies ReadonlyArray>>; protected readonly timeSegmentMinValuesOptions = [ {}, {hours: 1}, ] as const satisfies ReadonlyArray>>; public mode: MaskitoTimeMode = this.modeOptions[0]; public timeSegmentMinValues = this.timeSegmentMinValuesOptions[0]; public timeSegmentMaxValues = this.timeSegmentMaxValuesOptions[0]; public prefix = ''; public postfix = ''; public step = 0; public maskitoOptions: MaskitoOptions = maskitoTimeOptionsGenerator(this); protected updateOptions(): void { this.maskitoOptions = maskitoTimeOptionsGenerator(this); } } ================================================ FILE: projects/demo/src/pages/kit/time/time-mask-doc.style.less ================================================ .input-time { max-inline-size: 25rem; &:not(:last-child) { margin-block-end: 1rem; } } ================================================ FILE: projects/demo/src/pages/kit/time/time-mask-doc.template.html ================================================ Use maskitoTimeOptionsGenerator to create a mask for time input.
Despite the name of the mask, element's raw value is still string.

Use maskitoParseTime to get milliseconds from masked string.

Use maskitoStringifyTime to get the masked string from milliseconds.

Use mode property to set time format. See the full list of available mode on API page of the documentation.

Any mode ending with AA is 12-hour time format with meridiem part.

Property step allows you to increment/decrement time segments by pressing ArrowUp / ArrowDown .

Use step === 0 (default value) to disable this feature.

Use prefix / postfix parameters to set non-removable text before / after the time.

Property timeSegmentMinValues / timeSegmentMaxValues allows you to set min/max value for every time segment.

Time segments are units of the time which form time string. For example, HH:MM consists of two time segments: hours and minutes.

Enter time Time format mode Minimum value for each time segment

Default:
{hours: 0}  /  {hours: 1} for mode without / with meridiem period

Maximum value for each time segment

Default:
{hours: 24}  /  {hours: 12} for mode without / with meridiem period

The value by which the keyboard arrows increment/decrement time segments

Default: 0 (disable stepping)

Uneditable text before time

Default: empty string (no prefix).

Uneditable text after time

Default: empty string (no postfix).

================================================ FILE: projects/demo/src/pages/pages.ts ================================================ import {DemoPath} from '@demo/constants'; import type {TuiDocRoutePages} from '@taiga-ui/addon-doc'; export const DEMO_PAGES: TuiDocRoutePages = [ { section: 'Getting started', title: 'What is Maskito?', route: DemoPath.WhatIsMaskito, keywords: 'getting, started, what, is, maskito', }, { section: 'Getting started', title: 'Maskito libraries', route: DemoPath.MaskitoLibraries, keywords: 'install, package, packages, maskito, npm, setup, explore, ecosystem', }, { section: 'Core concepts', title: 'Overview', route: DemoPath.CoreConceptsOverview, keywords: 'core, concepts, overview', }, { section: 'Core concepts', title: 'Mask expression', route: DemoPath.MaskExpression, keywords: 'core, concepts, mask, expression, reg, exp, fixed', }, { section: 'Core concepts', title: 'Element state', route: DemoPath.ElementState, keywords: 'core, concepts, element, state', }, { section: 'Core concepts', title: 'Processors', route: DemoPath.Processors, keywords: 'core, concepts, preprocessor, postprocessor, processor, element, state, elementState', }, { section: 'Core concepts', title: 'Plugins', route: DemoPath.Plugins, keywords: 'core, concepts, extension, event, focus, blur', }, { section: 'Core concepts', title: 'Overwrite mode', route: DemoPath.OverwriteMode, keywords: 'core, concepts, overwrite, mode, shift, replace', }, { section: 'Core concepts', title: 'Transformer', route: DemoPath.Transformer, keywords: 'core, concepts, programmatic, patch, set, update, value', }, { section: 'Frameworks', title: 'Angular', route: DemoPath.Angular, keywords: 'ng, angular, framework, addon', }, { section: 'Frameworks', title: 'React', route: DemoPath.React, keywords: 'react, framework, addon', }, { section: 'Frameworks', title: 'Vue', route: DemoPath.Vue, keywords: 'vue, framework, addon', }, { section: 'Kit', title: 'Number', route: DemoPath.Number, keywords: 'digit, number, money, mask, kit, generator, big, int, integer, bigint', }, { section: 'Kit', title: 'Time', route: DemoPath.Time, keywords: 'time, hour, minute, second, mask, kit, generator', }, { section: 'Kit', title: 'Date', route: DemoPath.Date, keywords: 'date, day, month, year, mask, kit, generator', }, { section: 'Kit', title: 'DateRange', route: DemoPath.DateRange, keywords: 'date, day, month, year, mask, range, kit, generator', }, { section: 'Kit', title: 'DateTime', route: DemoPath.DateTime, keywords: 'date, day, month, year, mask, time, date-time, hour, minute, second, kit, generator', }, { section: 'Kit', title: 'List of Plugins', route: DemoPath.KitPlugins, keywords: 'reject, caret, guard, event, handler, focus, blur, selection', }, { section: 'Addons', title: '@maskito/phone', route: DemoPath.PhonePackage, keywords: 'phone, libphonenumber, international, generator', }, { section: 'Recipes', title: 'Card', route: DemoPath.Card, keywords: 'card, credit, cvv, debit, mask, recipe', }, { section: 'Recipes', title: 'Phone', route: DemoPath.Phone, keywords: 'phone, mobile, tel, telephone, mask, recipe', }, { section: 'Recipes', title: 'Textarea', route: DemoPath.Textarea, keywords: 'textarea, latin, mask, recipe', }, { section: 'Recipes', title: 'ContentEditable', route: DemoPath.ContentEditable, keywords: 'content, editable, contenteditable, contentEditable, mask, recipe', }, { section: 'Recipes', title: 'With prefix', route: DemoPath.Prefix, keywords: 'prefix, before, recipe', }, { section: 'Recipes', title: 'With postfix', route: DemoPath.Postfix, keywords: 'postfix, after, percent, am, pm, recipe', }, { section: 'Recipes', title: 'With placeholder', route: DemoPath.Placeholder, keywords: 'guide, placeholder, fill, recipe', }, { section: 'Recipes', title: 'Network address', route: DemoPath.NetworkAddress, keywords: 'ipv6, ipv4, ip, mac, address, network, recipe', }, { section: 'Other', title: 'Browser support', route: DemoPath.BrowserSupport, keywords: 'chrome, safari, ie, edge, firefox, browser, support', }, { section: 'Other', title: 'Supported types', route: DemoPath.SupportedInputTypes, keywords: 'input, type, text, password, search, tel, url, email, number, date, month', }, { section: 'Other', title: 'Maskito in Real World Form', route: DemoPath.RealWorldForm, keywords: 'browser, autofill, showcase, in, action, demo', }, { section: 'Other', title: 'Changelog', route: 'https://github.com/taiga-family/maskito/blob/main/CHANGELOG.md', target: '_blank', keywords: 'release, change, changelog, archive, history', }, ]; ================================================ FILE: projects/demo/src/pages/phone/examples/1-basic/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'phone-doc-example-1', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` Basic `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class PhoneMaskDocExample1 { protected value = '+7 771 931-1111'; protected readonly mask = mask; } ================================================ FILE: projects/demo/src/pages/phone/examples/1-basic/mask.ts ================================================ import {maskitoPhoneOptionsGenerator} from '@maskito/phone'; import metadata from 'libphonenumber-js/min/metadata'; export default maskitoPhoneOptionsGenerator({countryIsoCode: 'KZ', metadata}); ================================================ FILE: projects/demo/src/pages/phone/examples/2-validation/component.ts ================================================ import {AsyncPipe} from '@angular/common'; import {ChangeDetectionStrategy, Component} from '@angular/core'; import { type AbstractControl, FormControl, ReactiveFormsModule, type ValidationErrors, type ValidatorFn, } from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiValidationError} from '@taiga-ui/cdk'; import {TuiError} from '@taiga-ui/core'; import {TuiFieldErrorPipe} from '@taiga-ui/kit'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import {type CountryCode, isValidPhoneNumber} from 'libphonenumber-js/max'; import mask from './mask'; function phoneValidator(countryCode: CountryCode): ValidatorFn { return (control: AbstractControl): ValidationErrors | null => { const valid = isValidPhoneNumber(control.value, countryCode); return valid ? null : new TuiValidationError('Invalid number'); }; } @Component({ selector: 'phone-doc-example-2', imports: [ AsyncPipe, MaskitoDirective, ReactiveFormsModule, TuiError, TuiFieldErrorPipe, TuiInputModule, TuiTextfieldControllerModule, ], template: ` Basic `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class PhoneMaskDocExample2 { protected readonly control = new FormControl('+36 20 123-3122', phoneValidator('HU')); protected readonly mask = mask; } ================================================ FILE: projects/demo/src/pages/phone/examples/2-validation/mask.ts ================================================ import {maskitoPhoneOptionsGenerator} from '@maskito/phone'; import metadata from 'libphonenumber-js/mobile/metadata'; export default maskitoPhoneOptionsGenerator({countryIsoCode: 'HU', metadata}); ================================================ FILE: projects/demo/src/pages/phone/examples/3-non-strict/component.ts ================================================ import {ChangeDetectionStrategy, Component, inject} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {maskitoGetCountryFromNumber} from '@maskito/phone'; import {isSafari} from '@ng-web-apis/platform'; import {TUI_IS_IOS, tuiInjectElement} from '@taiga-ui/cdk'; import {TuiFlagPipe} from '@taiga-ui/core'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import metadata from 'libphonenumber-js/min/metadata'; import mask from './mask'; @Component({ selector: 'phone-doc-example-3', imports: [ FormsModule, MaskitoDirective, TuiFlagPipe, TuiInputModule, TuiTextfieldControllerModule, ], template: ` Non-strict `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class PhoneMaskDocExample3 { /** * https://github.com/taiga-family/maskito/pull/2165 * TODO: delete after bumping Safari support to 18+ */ protected readonly pattern = isSafari(tuiInjectElement()) || inject(TUI_IS_IOS) ? '+[0-9-]{1,20}' : ''; protected value = ''; protected readonly mask = mask; protected get countryIsoCode(): string { return maskitoGetCountryFromNumber(this.value, metadata) ?? ''; } } ================================================ FILE: projects/demo/src/pages/phone/examples/3-non-strict/mask.ts ================================================ import {maskitoPhoneOptionsGenerator} from '@maskito/phone'; import metadata from 'libphonenumber-js/min/metadata'; export default maskitoPhoneOptionsGenerator({ metadata, strict: false, countryIsoCode: 'RU', }); ================================================ FILE: projects/demo/src/pages/phone/examples/4-lazy-metadata/component.ts ================================================ import {ChangeDetectionStrategy, Component, type OnInit} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {MASKITO_DEFAULT_OPTIONS} from '@maskito/core'; import {maskitoPhoneOptionsGenerator} from '@maskito/phone'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; @Component({ selector: 'phone-doc-example-4', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` Lazy metadata `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class PhoneMaskDocExample4 implements OnInit { protected value = '+7 920 123-4567'; protected mask = MASKITO_DEFAULT_OPTIONS; public ngOnInit(): void { import('libphonenumber-js/min/metadata').then(({default: metadata}) => () => { this.mask = maskitoPhoneOptionsGenerator({countryIsoCode: 'RU', metadata}); }); } } ================================================ FILE: projects/demo/src/pages/phone/examples/4-lazy-metadata/simple.md ================================================ ```js import {Maskito, MASKITO_DEFAULT_OPTIONS} from '@maskito/core'; import {maskitoPhoneOptionsGenerator} from '@maskito/phone'; const element = document.querySelector('input,textarea'); let maskedInput; (async function initMask() { const maskitoOptions = maskitoPhoneOptionsGenerator({ countryIsoCode: 'RU', metadata: await import('libphonenumber-js/min/metadata').then((m) => m.default), }); maskedInput = new Maskito(element, maskitoOptions); })(); // Call this function when the element is detached from DOM maskedInput.destroy(); ``` ================================================ FILE: projects/demo/src/pages/phone/examples/5-focus-blur-events/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiFlagPipe} from '@taiga-ui/core'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'phone-doc-example-5', imports: [ FormsModule, MaskitoDirective, TuiFlagPipe, TuiInputModule, TuiTextfieldControllerModule, ], template: ` {{ textfield.focused ? 'Blur me to remove prefix' : 'Focus me to see prefix' }} Turkish flag `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class PhoneMaskDocExample5 { protected value = ''; protected readonly mask = mask; } ================================================ FILE: projects/demo/src/pages/phone/examples/5-focus-blur-events/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import {maskitoAddOnFocusPlugin, maskitoRemoveOnBlurPlugin} from '@maskito/kit'; import {maskitoPhoneOptionsGenerator} from '@maskito/phone'; import {getCountryCallingCode} from 'libphonenumber-js/core'; import metadata from 'libphonenumber-js/min/metadata'; const countryIsoCode = 'TR'; const code = getCountryCallingCode(countryIsoCode, metadata); const prefix = `+${code} `; const phoneOptions = maskitoPhoneOptionsGenerator({ metadata, countryIsoCode, strict: true, }); export default { ...phoneOptions, plugins: [ ...phoneOptions.plugins, maskitoAddOnFocusPlugin(prefix), maskitoRemoveOnBlurPlugin(prefix), ], } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/phone/examples/6-national-format/component.ts ================================================ import {ChangeDetectionStrategy, Component, inject} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {isSafari} from '@ng-web-apis/platform'; import {TUI_IS_IOS, tuiInjectElement} from '@taiga-ui/cdk'; import {TuiFlagPipe} from '@taiga-ui/core'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'phone-doc-example-6', imports: [ FormsModule, MaskitoDirective, TuiFlagPipe, TuiInputModule, TuiTextfieldControllerModule, ], template: ` National Format (US) US `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class PhoneMaskDocExample6 { protected value = ''; protected readonly mask = mask; /** * Pattern for iOS Safari to allow phone number input. * National format doesn't include '+', so pattern is different. * TODO: delete after bumping Safari support to 18+ */ protected readonly pattern = isSafari(tuiInjectElement()) || inject(TUI_IS_IOS) ? '[0-9()-]{1,20}' : ''; } ================================================ FILE: projects/demo/src/pages/phone/examples/6-national-format/mask.ts ================================================ import {maskitoPhoneOptionsGenerator} from '@maskito/phone'; import metadata from 'libphonenumber-js/min/metadata'; /** * National format phone mask for United States. * Displays phone numbers in national format: (XXX) XXX-XXXX * without the country code prefix. */ export default maskitoPhoneOptionsGenerator({ countryIsoCode: 'US', metadata, format: 'NATIONAL', }); ================================================ FILE: projects/demo/src/pages/phone/phone-doc.component.ts ================================================ import {ChangeDetectionStrategy, Component, inject} from '@angular/core'; import {FormControl, ReactiveFormsModule} from '@angular/forms'; import {DocExamplePrimaryTab} from '@demo/constants'; import {MaskitoDirective} from '@maskito/angular'; import type {MaskitoOptions} from '@maskito/core'; import {maskitoAddOnFocusPlugin, maskitoRemoveOnBlurPlugin} from '@maskito/kit'; import {maskitoPhoneOptionsGenerator, type MaskitoPhoneParams} from '@maskito/phone'; import {isSafari} from '@ng-web-apis/platform'; import {TuiAddonDoc, type TuiRawLoaderContent} from '@taiga-ui/addon-doc'; import {CHAR_PLUS, TUI_IS_IOS, tuiInjectElement} from '@taiga-ui/cdk'; import {TuiLink, TuiNotification} from '@taiga-ui/core'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import type {MetadataJson} from 'libphonenumber-js'; import { type CountryCode, getCountries, getCountryCallingCode, } from 'libphonenumber-js/core'; import maxMetadata from 'libphonenumber-js/max/metadata'; import minMetadata from 'libphonenumber-js/min/metadata'; import mobileMetadata from 'libphonenumber-js/mobile/metadata'; import {PhoneMaskDocExample1} from './examples/1-basic/component'; import {PhoneMaskDocExample2} from './examples/2-validation/component'; import {PhoneMaskDocExample3} from './examples/3-non-strict/component'; import {PhoneMaskDocExample4} from './examples/4-lazy-metadata/component'; import {PhoneMaskDocExample5} from './examples/5-focus-blur-events/component'; import {PhoneMaskDocExample6} from './examples/6-national-format/component'; const metadataSets = { min: minMetadata, max: maxMetadata, mobile: mobileMetadata, } as const satisfies Record; type GeneratorOptions = Required[0]>; type MetadataName = keyof typeof metadataSets; @Component({ selector: 'phone-doc', imports: [ MaskitoDirective, PhoneMaskDocExample1, PhoneMaskDocExample2, PhoneMaskDocExample3, PhoneMaskDocExample4, PhoneMaskDocExample5, PhoneMaskDocExample6, ReactiveFormsModule, TuiAddonDoc, TuiInputModule, TuiLink, TuiNotification, TuiTextfieldControllerModule, ], templateUrl: './phone-doc.template.html', styleUrl: './phone-doc.style.less', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class PhoneDocComponent implements GeneratorOptions { private readonly isApple = isSafari(tuiInjectElement()) || inject(TUI_IS_IOS); protected apiPageControl = new FormControl(''); protected readonly basic: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/1-basic/mask.ts?raw', { with: {loader: 'text'}, }), }; protected readonly validation: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/2-validation/mask.ts?raw', {with: {loader: 'text'}} ), [DocExamplePrimaryTab.Angular]: import( './examples/2-validation/component.ts?raw', {with: {loader: 'text'}} ), }; protected readonly nonStrict: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/3-non-strict/mask.ts?raw', {with: {loader: 'text'}} ), [DocExamplePrimaryTab.Angular]: import( './examples/3-non-strict/component.ts?raw', {with: {loader: 'text'}} ), }; protected readonly lazyMetadata: Record = { [DocExamplePrimaryTab.Angular]: import( './examples/4-lazy-metadata/component.ts?raw', {with: {loader: 'text'}} ), [DocExamplePrimaryTab.JavaScript]: import('./examples/4-lazy-metadata/simple.md'), }; protected readonly focusBlurEvents: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/5-focus-blur-events/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly nationalFormat: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/6-national-format/mask.ts?raw', {with: {loader: 'text'}} ), }; public strict = true; public countryIsoCode: CountryCode = 'RU'; public separator = '-'; public format: NonNullable = 'INTERNATIONAL'; public metadataVariants = Object.keys(metadataSets) as readonly MetadataName[]; public selectedMetadata = this.metadataVariants[0]!; public countryCodeVariants = getCountries(this.metadata); public separatorVariants = ['-', ' ']; public formatVariants: Array> = [ 'INTERNATIONAL', 'NATIONAL', ]; public maskitoOptions = this.computeOptions(); public get metadata(): MetadataJson { return metadataSets[this.selectedMetadata]; } /** * Pattern for iOS Safari to allow phone number input. * Different patterns for international vs national format. * TODO: delete after bumping Safari support to 18+ */ protected get pattern(): string { if (!this.isApple) { return ''; } return this.format === 'NATIONAL' ? '[0-9()-]{1,20}' : '+[0-9-]{1,20}'; } protected updateOptions(): void { this.maskitoOptions = this.computeOptions(); } private computeOptions(): Required { const options = maskitoPhoneOptionsGenerator(this); const code = getCountryCallingCode(this.countryIsoCode, this.metadata); const prefix = `${CHAR_PLUS}${code} `; return this.strict && this.format === 'INTERNATIONAL' ? { ...options, plugins: [ ...options.plugins, maskitoRemoveOnBlurPlugin(prefix), maskitoAddOnFocusPlugin(prefix), ], } : options; } } ================================================ FILE: projects/demo/src/pages/phone/phone-doc.style.less ================================================ .phone { max-inline-size: 25rem; &:not(:last-child) { margin-block-end: 1rem; } } ================================================ FILE: projects/demo/src/pages/phone/phone-doc.template.html ================================================

This mask is based on the libphonenumber-js package.

Use maskitoPhoneOptionsGenerator to create a mask for phone input.

For validating phone number you can use isValidPhoneNumber , isPossiblePhoneNumber functions from libphonenumber-js package. Read more

Below is an example of a Hungarian phone mask with an angular validator.

Setting the strict option to false enables non-strict mask mode and allow user to type any country phone number.

The countryIsoCode option is optional in that case, but if you specify it, the mask will try to add that country's calling code when you try to insert a phone number without a calling code.

You can load metadata lazily, below is an example of how to do it in Angular.

You can also customize the metadata to reduce metadata size. See instructions here

Use maskitoAddOnFocusPlugin / maskitoRemoveOnBlurPlugin to mutate textfield's value on focus/blur events.

Setting the format option to 'NATIONAL' displays phone numbers in the country's national format without the country code prefix.

For example, US numbers will be formatted as (212) 343-3355 instead of +1 212 343-3355 .

National format only works with defined countryIsoCode property!
Enter phone List of phone number parsing and formatting rules for all countries.

The complete list of those rules is huge, so libphonenumber-js provides a way to optimize bundle size by choosing between max , min , mobile metadata.

Country ISO-code If true, it allows to enter only phone number of selected country (see countryIsoCode property). If false, all country phone number is allowed.

Default: true

Separator between groups of numbers in a phone number (excluding country code and area code).

Default: -

Phone number format. 'INTERNATIONAL' includes the country code prefix (e.g., +1 212 343-3355 ). 'NATIONAL' uses country-specific formatting without the country code (e.g., (212) 343-3355 for US).

Note: 'NATIONAL' format only works with strict: true mode.

Default: 'INTERNATIONAL'

================================================ FILE: projects/demo/src/pages/recipes/card/card-doc.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {RouterLink} from '@angular/router'; import {DemoPath} from '@demo/constants'; import {TuiAddonDoc, type TuiRawLoaderContent} from '@taiga-ui/addon-doc'; import {TuiLink} from '@taiga-ui/core'; import {CardDocExample1} from './examples/1-basic/component'; @Component({ selector: 'card-doc', imports: [CardDocExample1, RouterLink, TuiAddonDoc, TuiLink], templateUrl: './card-doc.template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class CardDocComponent { protected readonly maskExpressionDocPage = `/${DemoPath.MaskExpression}`; protected readonly dateMaskDocPage = `/${DemoPath.Date}`; protected readonly cardExample1: Record = { TypeScript: import('./examples/1-basic/component.ts?raw', { with: {loader: 'text'}, }), HTML: import('./examples/1-basic/template.html'), }; } ================================================ FILE: projects/demo/src/pages/recipes/card/card-doc.template.html ================================================

Creating mask for credit card input requires basic understanding of the following topics:

================================================ FILE: projects/demo/src/pages/recipes/card/examples/1-basic/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import type {MaskitoOptions} from '@maskito/core'; import {maskitoDateOptionsGenerator} from '@maskito/kit'; import {TuiGroup} from '@taiga-ui/core'; import {TuiInputModule} from '@taiga-ui/legacy'; @Component({ selector: 'card-doc-example-1', imports: [MaskitoDirective, ReactiveFormsModule, TuiGroup, TuiInputModule], templateUrl: './template.html', styleUrl: './style.less', changeDetection: ChangeDetectionStrategy.OnPush, }) export class CardDocExample1 { protected readonly cardMask: MaskitoOptions = { mask: [ ...Array.from({length: 4}).fill(/\d/), ' ', ...Array.from({length: 4}).fill(/\d/), ' ', ...Array.from({length: 4}).fill(/\d/), ' ', ...Array.from({length: 4}).fill(/\d/), ' ', ...Array.from({length: 3}).fill(/\d/), ], }; protected readonly expiredMask = maskitoDateOptionsGenerator({ mode: 'mm/yy', separator: '/', }); protected readonly cvvMask: MaskitoOptions = { mask: Array.from({length: 3}).fill(/\d/), }; protected readonly form = new FormGroup({ cardNumber: new FormControl(''), expire: new FormControl(''), cvv: new FormControl(''), }); } ================================================ FILE: projects/demo/src/pages/recipes/card/examples/1-basic/style.less ================================================ .wrapper { display: flex; max-inline-size: 30rem; } .number { flex: 1 1 11rem; } .cvv { flex: 1 0 4rem; } .expired { flex: 1 0 5rem; } ================================================ FILE: projects/demo/src/pages/recipes/card/examples/1-basic/template.html ================================================
Card number EXP CVV
================================================ FILE: projects/demo/src/pages/recipes/content-editable/content-editable-doc.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {RouterLink} from '@angular/router'; import {DemoPath, DocExamplePrimaryTab} from '@demo/constants'; import {TuiAddonDoc, type TuiRawLoaderContent} from '@taiga-ui/addon-doc'; import {TuiLink, TuiNotification} from '@taiga-ui/core'; import {ContentEditableDocExample1} from './examples/1-time/component'; import {ContentEditableDocExample2} from './examples/2-multi-line/component'; @Component({ selector: 'content-editable-doc', imports: [ ContentEditableDocExample1, ContentEditableDocExample2, RouterLink, TuiAddonDoc, TuiLink, TuiNotification, ], templateUrl: './content-editable-doc.template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class ContentEditableDocComponent { protected readonly coreConceptsOverviewDocPage = `/${DemoPath.CoreConceptsOverview}`; protected readonly timeMaskDocPage = `/${DemoPath.Time}`; protected readonly angularDocPage = `/${DemoPath.Angular}`; protected readonly reactDocPage = `/${DemoPath.React}`; protected readonly vueDocPage = `/${DemoPath.Vue}`; protected readonly maskitoWithContentEditableDemo = import('./examples/maskito-with-content-editable.md'); protected readonly contentEditableExample1: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/1-time/mask.ts?raw', { with: {loader: 'text'}, }), [DocExamplePrimaryTab.JavaScript]: import('./examples/vanilla-js-tab.md'), [DocExamplePrimaryTab.Angular]: import('./examples/1-time/component.ts?raw', { with: {loader: 'text'}, }), }; protected readonly contentEditableExample2: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/2-multi-line/mask.ts?raw', {with: {loader: 'text'}} ), [DocExamplePrimaryTab.JavaScript]: import('./examples/vanilla-js-tab.md'), [DocExamplePrimaryTab.Angular]: import( './examples/2-multi-line/component.ts?raw', {with: {loader: 'text'}} ), }; } ================================================ FILE: projects/demo/src/pages/recipes/content-editable/content-editable-doc.template.html ================================================

You can use Maskito with contentEditable too.

Just wrap the element with maskitoAdaptContentEditable utility and use Maskito in the same way as HTMLInputElement / HTMLTextAreaElement .

No need to use maskitoAdaptContentEditable if you use @maskito/angular , @maskito/react or @maskito/vue with the default element predicate (it will be wrapped automatically).

Learn more in the "Core Concepts" section.

With built-in Time mask Use white-space: pre for multi-line mode
================================================ FILE: projects/demo/src/pages/recipes/content-editable/examples/1-time/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {MaskitoDirective} from '@maskito/angular'; import mask from './mask'; @Component({ selector: 'content-editable-doc-example-1', imports: [MaskitoDirective], template: ` Meeting time: `, styles: [ ':host {font-size: 1.75rem}', '[contenteditable] {border: 3px dashed lightgrey}', ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ContentEditableDocExample1 { protected initialValue = '12:00'; protected readonly mask = mask; } ================================================ FILE: projects/demo/src/pages/recipes/content-editable/examples/1-time/mask.ts ================================================ import {maskitoTimeOptionsGenerator} from '@maskito/kit'; export default maskitoTimeOptionsGenerator({mode: 'HH:MM'}); ================================================ FILE: projects/demo/src/pages/recipes/content-editable/examples/2-multi-line/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {MaskitoDirective} from '@maskito/angular'; import mask from './mask'; @Component({ selector: 'content-editable-doc-example-2', imports: [MaskitoDirective], template: ` Enter message:

`, styles: [ ` [contenteditable] { white-space: pre; border: 3px dashed lightgrey; max-width: 30rem; padding: 1rem; } `, ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ContentEditableDocExample2 { protected readonly mask = mask; protected initialText = `Hello, world! How are you today? Read description of this example!`; } ================================================ FILE: projects/demo/src/pages/recipes/content-editable/examples/2-multi-line/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; export default {mask: /^[a-z\s.,/!?]+$/i} satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/recipes/content-editable/examples/maskito-with-content-editable.md ================================================ ```ts import {Maskito, maskitoAdaptContentEditable, MaskitoOptions} from '@maskito/core'; const maskitoOptions: MaskitoOptions = { mask: /^\d+$/, }; const element = document.querySelector('[contenteditable]')!; const maskedInput = new Maskito( maskitoAdaptContentEditable(element), // <-- This is the only difference maskitoOptions, ); ``` ================================================ FILE: projects/demo/src/pages/recipes/content-editable/examples/vanilla-js-tab.md ================================================ ```ts import {Maskito, maskitoAdaptContentEditable} from '@maskito/core'; import maskitoOptions from './mask'; const element = document.querySelector('[contenteditable]')!; const maskedInput = new Maskito(maskitoAdaptContentEditable(element), maskitoOptions); console.info('Call this function when the element is detached from DOM', maskedInput.destroy); ``` ================================================ FILE: projects/demo/src/pages/recipes/network-address/examples/1-ipv6/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'network-address-doc-example-1', imports: [FormsModule, MaskitoDirective, TuiInputModule], template: ` Enter IPv6 address `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class NetworkAddressDocExample1 { protected readonly maskitoOptions = mask; protected value = ''; } ================================================ FILE: projects/demo/src/pages/recipes/network-address/examples/1-ipv6/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; const HEX_GROUP = Array.from({length: 4}, () => /[0-9A-F]/i); export default { mask: Array.from({length: 8}, (_, i) => (i ? [':', ...HEX_GROUP] : HEX_GROUP)).flat(), postprocessors: [({value, selection}) => ({value: value.toLowerCase(), selection})], } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/recipes/network-address/examples/2-ipv4/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'network-address-doc-example-2', imports: [FormsModule, MaskitoDirective, TuiInputModule], template: ` Enter IPv4 address `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class NetworkAddressDocExample2 { protected readonly maskitoOptions = mask; protected value = ''; } ================================================ FILE: projects/demo/src/pages/recipes/network-address/examples/2-ipv4/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; const MAX_OCTET_VALUE = 255; const MAX_OCTET_LENGTH = 3; const MAX_OCTETS = 4; const SEPARATOR = '.'; const DIGIT = /\d/; export default { mask: ({value}) => { const octets = value .split(new RegExp(`(\\${SEPARATOR}|${DIGIT.source}{${MAX_OCTET_LENGTH}})`)) .filter((x) => x && x !== SEPARATOR); return octets .map((octet, i) => { const length = Math.max(1, Math.min(MAX_OCTET_LENGTH, octet.length)); const group = Array.from({length}, () => DIGIT); const last = i === octets.length - 1; return last ? group : [...group, SEPARATOR]; }) .concat([octets.length ? [SEPARATOR, DIGIT] : [DIGIT]]) .slice(0, MAX_OCTETS) .flat(); }, postprocessors: [ ({value, selection}) => ({ value: value .split(SEPARATOR) .map((v) => (Number(v) > MAX_OCTET_VALUE ? String(MAX_OCTET_VALUE) : v)) .join(SEPARATOR), selection, }), ], } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/recipes/network-address/examples/3-mac/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'network-address-doc-example-3', imports: [FormsModule, MaskitoDirective, TuiInputModule], template: ` Enter MAC address `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class NetworkAddressDocExample3 { protected readonly maskitoOptions = mask; protected value = ''; } ================================================ FILE: projects/demo/src/pages/recipes/network-address/examples/3-mac/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; const HEX_GROUP = Array.from({length: 2}, () => /[0-9A-F]/i); export default { mask: Array.from({length: 6}, (_, i) => (i ? [':', ...HEX_GROUP] : HEX_GROUP)).flat(), postprocessors: [({value, selection}) => ({value: value.toUpperCase(), selection})], } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/recipes/network-address/network-address-doc.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {DocExamplePrimaryTab} from '@demo/constants'; import {TuiAddonDoc, type TuiRawLoaderContent} from '@taiga-ui/addon-doc'; import {NetworkAddressDocExample1} from './examples/1-ipv6/component'; import {NetworkAddressDocExample2} from './examples/2-ipv4/component'; import {NetworkAddressDocExample3} from './examples/3-mac/component'; @Component({ selector: 'network-address-doc', imports: [ NetworkAddressDocExample1, NetworkAddressDocExample2, NetworkAddressDocExample3, TuiAddonDoc, ], templateUrl: './network-address-doc.template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class NetworkAddressDocComponent { protected readonly ipv6Example1: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/1-ipv6/mask.ts?raw', { with: {loader: 'text'}, }), }; protected readonly ipv4Example2: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/2-ipv4/mask.ts?raw', { with: {loader: 'text'}, }), }; protected readonly macExample3: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/3-mac/mask.ts?raw', { with: {loader: 'text'}, }), }; } ================================================ FILE: projects/demo/src/pages/recipes/network-address/network-address-doc.template.html ================================================

Use mask expression to create inputs for network addresses such as IPv6, IPv4, and MAC.

This example demonstrates how to create an IPv6 address input via pattern mask expression with fixed colon separators. A postprocessor converts all characters to lowercase. This example demonstrates how to create an IPv4 address input via dynamic mask expression. It creates up to 4 octets separated by dots. Each octet allows up to 3 digits. A postprocessor clamps each octet value to a maximum of 255. This example demonstrates how to create a MAC address input via pattern mask expression with fixed colon separators. A postprocessor converts all characters to uppercase.
================================================ FILE: projects/demo/src/pages/recipes/phone/examples/1-us-phone/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiFlagPipe} from '@taiga-ui/core'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'phone-doc-example-1', imports: [ FormsModule, MaskitoDirective, TuiFlagPipe, TuiInputModule, TuiTextfieldControllerModule, ], template: ` Enter a phone number Flag of the United States `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class PhoneUSDocExample1 { protected readonly maskitoOptions = mask; protected value = '+1 (212) 555-2368'; } ================================================ FILE: projects/demo/src/pages/recipes/phone/examples/1-us-phone/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; export default { mask: [ '+', '1', ' ', '(', /\d/, /\d/, /\d/, ')', ' ', /\d/, /\d/, /\d/, '-', /\d/, /\d/, /\d/, /\d/, ], } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/recipes/phone/examples/2-kz-phone/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormControl, ReactiveFormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiFlagPipe, TuiTextfield} from '@taiga-ui/core'; import mask from './mask'; @Component({ selector: 'phone-doc-example-2', imports: [MaskitoDirective, ReactiveFormsModule, TuiFlagPipe, TuiTextfield], templateUrl: './template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class PhoneKZDocExample2 { protected readonly maskitoOptions = mask; protected readonly control = new FormControl(''); } ================================================ FILE: projects/demo/src/pages/recipes/phone/examples/2-kz-phone/mask.ts ================================================ import type {MaskitoOptions, MaskitoPreprocessor} from '@maskito/core'; import { maskitoAddOnFocusPlugin, maskitoCaretGuard, maskitoPrefixPostprocessorGenerator, maskitoRemoveOnBlurPlugin, } from '@maskito/kit'; export default { mask: [ '+', '7', ' ', '(', /\d/, /\d/, /\d/, ')', ' ', /\d/, /\d/, /\d/, '-', /\d/, /\d/, '-', /\d/, /\d/, ], postprocessors: [ // non-removable country prefix maskitoPrefixPostprocessorGenerator('+7 '), ], preprocessors: [createCompletePhoneInsertionPreprocessor()], plugins: [ maskitoAddOnFocusPlugin('+7 '), maskitoRemoveOnBlurPlugin('+7 '), // Forbids to put caret before non-removable country prefix // But allows to select all value! maskitoCaretGuard((value, [from, to]) => [ from === to ? '+7 '.length : 0, value.length, ]), ], } satisfies MaskitoOptions; // Paste "89123456789" => "+7 (912) 345-67-89" function createCompletePhoneInsertionPreprocessor(): MaskitoPreprocessor { const trimPrefix = (value: string): string => value.replace(/^\+?7?\s?8?\s?/, ''); const countDigits = (value: string): number => value.replaceAll(/\D/g, '').length; return ({elementState, data}) => { const {value, selection} = elementState; return { elementState: { selection, value: countDigits(value) > 11 ? trimPrefix(value) : value, }, data: countDigits(data) >= 11 ? trimPrefix(data) : data, }; }; } ================================================ FILE: projects/demo/src/pages/recipes/phone/examples/2-kz-phone/template.html ================================================ Flag of Kazakhstan ================================================ FILE: projects/demo/src/pages/recipes/phone/phone-doc.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {RouterLink} from '@angular/router'; import {DemoPath, DocExamplePrimaryTab} from '@demo/constants'; import {TuiAddonDoc, type TuiRawLoaderContent} from '@taiga-ui/addon-doc'; import {TuiLink} from '@taiga-ui/core'; import {PhoneUSDocExample1} from './examples/1-us-phone/component'; import {PhoneKZDocExample2} from './examples/2-kz-phone/component'; @Component({ selector: 'phone-doc', imports: [PhoneKZDocExample2, PhoneUSDocExample1, RouterLink, TuiAddonDoc, TuiLink], templateUrl: './phone-doc.template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class PhoneDocComponent { protected readonly maskExpressionDocPage = `/${DemoPath.MaskExpression}`; protected readonly prefixDocPage = `/${DemoPath.Prefix}`; protected readonly usPhoneExample1: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/1-us-phone/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly kzPhoneExample2: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/2-kz-phone/mask.ts?raw', {with: {loader: 'text'}} ), }; } ================================================ FILE: projects/demo/src/pages/recipes/phone/phone-doc.template.html ================================================

Creating mask for a phone number is simple. The only required knowledge is the pattern mask expression with fixed characters . Read more about it in "Mask expression" section.

This page demonstrates some examples for different countries.

The following example demonstrates a more complex mask. It shows how to make the country prefix non-removable. It is achieved by built-in postprocessor from @maskito/kit .

Read more about it in "With prefix" section.

================================================ FILE: projects/demo/src/pages/recipes/placeholder/examples/1-cvc-code/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'placeholder-doc-example-1', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` Enter CVC code `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class PlaceholderDocExample1 { protected readonly maskitoOptions = mask; protected value = 'xxx'; } ================================================ FILE: projects/demo/src/pages/recipes/placeholder/examples/1-cvc-code/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import {maskitoWithPlaceholder} from '@maskito/kit'; export default { ...maskitoWithPlaceholder('xxx'), mask: /^\d{0,3}$/, } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/recipes/placeholder/examples/2-phone/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiFlagPipe} from '@taiga-ui/core'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'placeholder-doc-example-2', imports: [ FormsModule, MaskitoDirective, TuiFlagPipe, TuiInputModule, TuiTextfieldControllerModule, ], template: ` Enter US phone number Flag of the United States `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class PlaceholderDocExample2 { protected readonly maskitoOptions = mask; protected value = ''; } ================================================ FILE: projects/demo/src/pages/recipes/placeholder/examples/2-phone/mask.ts ================================================ import {type MaskitoOptions, maskitoUpdateElement} from '@maskito/core'; import { maskitoEventHandler, maskitoPrefixPostprocessorGenerator, maskitoWithPlaceholder, } from '@maskito/kit'; /** * It is better to use en quad for placeholder characters * instead of simple space. * @see https://symbl.cc/en/2000 */ const PLACEHOLDER = '+  (   ) ___-____'; const { /** * Use this utility to remove placeholder characters * ___ * @example * inputRef.addEventListener('blur', () => { * // removePlaceholder('+1 (212) 555-____') => '+1 (212) 555' * const cleanValue = removePlaceholder(this.value); * * inputRef.value = cleanValue === '+1' ? '' : cleanValue; * }); */ removePlaceholder, plugins, ...placeholderOptions } = maskitoWithPlaceholder(PLACEHOLDER); export default { preprocessors: placeholderOptions.preprocessors, postprocessors: [ maskitoPrefixPostprocessorGenerator('+1'), ...placeholderOptions.postprocessors, ], mask: [ '+', '1', ' ', '(', /\d/, /\d/, /\d/, ')', ' ', /\d/, /\d/, /\d/, '-', /\d/, /\d/, /\d/, /\d/, ], plugins: [ ...plugins, maskitoEventHandler('focus', (element) => { const initialValue = element.value || '+1 ('; maskitoUpdateElement( element, `${initialValue}${PLACEHOLDER.slice(initialValue.length)}`, ); }), maskitoEventHandler('blur', (element) => { const cleanValue = removePlaceholder(element.value); maskitoUpdateElement(element, cleanValue === '+1' ? '' : cleanValue); }), ], } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/recipes/placeholder/examples/3-date/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule, TuiTextfieldControllerModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'placeholder-doc-example-3', imports: [ FormsModule, MaskitoDirective, TuiInputModule, TuiTextfieldControllerModule, ], template: ` Enter date `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class PlaceholderDocExample3 { protected readonly maskitoOptions = mask; protected value = ''; } ================================================ FILE: projects/demo/src/pages/recipes/placeholder/examples/3-date/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import {maskitoDateOptionsGenerator, maskitoWithPlaceholder} from '@maskito/kit'; export const PLACEHOLDER = 'dd/mm/yyyy'; const dateOptions = maskitoDateOptionsGenerator({ mode: 'dd/mm/yyyy', separator: '/', }); const { plugins, // plugins keeps caret inside actual value and remove placeholder on blur ...placeholderOptions // pass 'true' as second argument to add plugin to hide placeholder when input is not focused } = maskitoWithPlaceholder(PLACEHOLDER, true); export default { ...dateOptions, plugins: plugins.concat(dateOptions.plugins), preprocessors: [ // Always put it BEFORE all other preprocessors ...placeholderOptions.preprocessors, ...dateOptions.preprocessors, ], postprocessors: [ ...dateOptions.postprocessors, // Always put it AFTER all other postprocessors ...placeholderOptions.postprocessors, ], } satisfies Required; ================================================ FILE: projects/demo/src/pages/recipes/placeholder/placeholder-doc.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {RouterLink} from '@angular/router'; import {DemoPath, DocExamplePrimaryTab} from '@demo/constants'; import {TuiAddonDoc, type TuiRawLoaderContent} from '@taiga-ui/addon-doc'; import {TuiLink} from '@taiga-ui/core'; import {PlaceholderDocExample1} from './examples/1-cvc-code/component'; import {PlaceholderDocExample2} from './examples/2-phone/component'; import {PlaceholderDocExample3} from './examples/3-date/component'; @Component({ selector: 'placeholder-doc', imports: [ PlaceholderDocExample1, PlaceholderDocExample2, PlaceholderDocExample3, RouterLink, TuiAddonDoc, TuiLink, ], templateUrl: './placeholder-doc.template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class PlaceholderDocComponent { protected readonly maskExpressionDocPage = `/${DemoPath.MaskExpression}`; protected readonly processorsDocPage = `/${DemoPath.Processors}`; protected readonly pluginsDocPage = `/${DemoPath.Plugins}`; protected readonly prefixDocPage = `/${DemoPath.Prefix}`; protected readonly cvcExample1: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/1-cvc-code/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly phoneExample2: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/2-phone/mask.ts?raw', { with: {loader: 'text'}, }), }; protected readonly dateExample3: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/3-date/mask.ts?raw', { with: {loader: 'text'}, }), }; } ================================================ FILE: projects/demo/src/pages/recipes/placeholder/placeholder-doc.template.html ================================================

maskitoWithPlaceholder helps to show placeholder mask characters. The placeholder character represents the fillable spot in the mask.

This example is the simplest demonstration how to create masked input with placeholder .

The only required prerequisite is basic understanding of "Mask expression" concept.

The following example explains return type of maskitoWithPlaceholder utility — an object which partially implements MaskitoOptions interface. It contains its own processor and postprocessor and plugins to keep caret from getting into placeholder part of the value.

Also, this complex example uses built-in postprocessor maskitoPrefixPostprocessorGenerator from @maskito/kit .

This last example demonstrates how to integrate maskitoWithPlaceholder with any built-in mask from @maskito/kit .
================================================ FILE: projects/demo/src/pages/recipes/postfix/examples/1-pattern-mask/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'postfix-doc-example-1', imports: [FormsModule, MaskitoDirective, TuiInputModule], template: ` Enter percentage amount `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class PostfixDocExample1 { protected readonly maskitoOptions = mask; protected value = ''; } ================================================ FILE: projects/demo/src/pages/recipes/postfix/examples/1-pattern-mask/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; export default { mask: ({value}) => { const digitsMask = Array.from(value.replaceAll('%', '')).map(() => /\d/); if (!digitsMask.length) { return [/\d/]; } return [...digitsMask, '%']; }, } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/recipes/postfix/examples/2-postprocessor/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'postfix-doc-example-2', imports: [FormsModule, MaskitoDirective, TuiInputModule], template: ` Enter price `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class PostfixDocExample2 { protected readonly maskitoOptions = mask; protected value = ''; } ================================================ FILE: projects/demo/src/pages/recipes/postfix/examples/2-postprocessor/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import { maskitoAddOnFocusPlugin, maskitoCaretGuard, maskitoPostfixPostprocessorGenerator, maskitoPrefixPostprocessorGenerator, maskitoRemoveOnBlurPlugin, } from '@maskito/kit'; export default { // prefix (dollar sign) + digits + postfix ('.00') mask: /^\$?\d*(\.0{0,2})?$/, postprocessors: [ maskitoPrefixPostprocessorGenerator('$'), maskitoPostfixPostprocessorGenerator('.00'), ], plugins: [ maskitoAddOnFocusPlugin('$.00'), maskitoRemoveOnBlurPlugin('$.00'), // Disallow to put caret before the prefix or after the postfix maskitoCaretGuard((value) => ['$'.length, value.length - '.00'.length]), ], } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/recipes/postfix/postfix-doc.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {RouterLink} from '@angular/router'; import {DemoPath, DocExamplePrimaryTab} from '@demo/constants'; import {TuiAddonDoc, type TuiRawLoaderContent} from '@taiga-ui/addon-doc'; import {TuiLink, TuiNotification} from '@taiga-ui/core'; import {PostfixDocExample1} from './examples/1-pattern-mask/component'; import {PostfixDocExample2} from './examples/2-postprocessor/component'; @Component({ selector: 'postfix-doc', imports: [ PostfixDocExample1, PostfixDocExample2, RouterLink, TuiAddonDoc, TuiLink, TuiNotification, ], templateUrl: './postfix-doc.template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class PostfixDocComponent { protected readonly maskExpressionDocPage = `/${DemoPath.MaskExpression}`; protected readonly processorsDocPage = `/${DemoPath.Processors}`; protected readonly patternMaskApproachExample1: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/1-pattern-mask/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly postprocessorApproachExample2: Record< string, TuiRawLoaderContent > = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/2-postprocessor/mask.ts?raw', {with: {loader: 'text'}} ), }; } ================================================ FILE: projects/demo/src/pages/recipes/postfix/postfix-doc.template.html ================================================ There are two approaches to add postfix for masked input. Every approach has its own behaviour and requires basic understanding of different core concepts. This example demonstrates how to create postfix via dynamic pattern mask expression . Percent symbol is a trailing fixed character, which will be automatically added when user enters the first digit.

This example demonstrates how to create postfix via postprocessor . It provides more flexibility, and you can configure any desired behaviour. You can use built-in maskitoPostfixPostprocessorGenerator or create your own.

Don't forget that mask property should be compatible with a new prefix / postfix!
================================================ FILE: projects/demo/src/pages/recipes/prefix/examples/1-pattern-mask/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'prefix-doc-example-1', imports: [FormsModule, MaskitoDirective, TuiInputModule], template: ` Enter price `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class PrefixDocExample1 { protected readonly maskitoOptions = mask; protected value = ''; } ================================================ FILE: projects/demo/src/pages/recipes/prefix/examples/1-pattern-mask/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; export default { mask: ({value}) => { const digitsCount = value.replaceAll(/\D/g, '').length; return ['$', ...Array.from({length: digitsCount || 1}).fill(/\d/)]; }, } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/recipes/prefix/examples/2-postprocessor/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiInputModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'prefix-doc-example-2', imports: [FormsModule, MaskitoDirective, TuiInputModule], template: ` Enter price `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class PrefixDocExample2 { protected readonly maskitoOptions = mask; protected value = ''; } ================================================ FILE: projects/demo/src/pages/recipes/prefix/examples/2-postprocessor/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import { maskitoAddOnFocusPlugin, maskitoPrefixPostprocessorGenerator, maskitoRemoveOnBlurPlugin, } from '@maskito/kit'; export default { mask: /^\$?\d*$/, // dollar sign or digits postprocessors: [maskitoPrefixPostprocessorGenerator('$')], plugins: [maskitoAddOnFocusPlugin('$'), maskitoRemoveOnBlurPlugin('$')], } satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/recipes/prefix/prefix-doc.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {RouterLink} from '@angular/router'; import {DemoPath, DocExamplePrimaryTab} from '@demo/constants'; import {TuiAddonDoc, type TuiRawLoaderContent} from '@taiga-ui/addon-doc'; import {TuiLink, TuiNotification} from '@taiga-ui/core'; import {PrefixDocExample1} from './examples/1-pattern-mask/component'; import {PrefixDocExample2} from './examples/2-postprocessor/component'; @Component({ selector: 'prefix-doc', imports: [ PrefixDocExample1, PrefixDocExample2, RouterLink, TuiAddonDoc, TuiLink, TuiNotification, ], templateUrl: './prefix-doc.template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class PrefixDocComponent { protected readonly maskExpressionDocPage = `/${DemoPath.MaskExpression}`; protected readonly processorsDocPage = `/${DemoPath.Processors}`; protected readonly patternMaskApproachExample1: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/1-pattern-mask/mask.ts?raw', {with: {loader: 'text'}} ), }; protected readonly postprocessorApproachExample2: Record< string, TuiRawLoaderContent > = { [DocExamplePrimaryTab.MaskitoOptions]: import( './examples/2-postprocessor/mask.ts?raw', {with: {loader: 'text'}} ), }; } ================================================ FILE: projects/demo/src/pages/recipes/prefix/prefix-doc.template.html ================================================

Use prefixes to indicate things like currencies, area / phone country codes and etc.

There are two approaches to add prefix for masked input. Every approach has its own behaviour and requires basic understanding of different core concepts.

This example demonstrates how to create prefix via dynamic pattern mask expression . Dollar symbol is a fixed character, which will be automatically added when user forgets to type it or deleted when user erase all digits.

This example demonstrates how to create prefix via postprocessor . It provides more flexibility, and you can configure any desired behaviour. You can use built-in maskitoPrefixPostprocessorGenerator or create your own.

Don't forget that mask property should be compatible with a new prefix!
================================================ FILE: projects/demo/src/pages/recipes/textarea/examples/1-latin/component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {TuiTextareaModule} from '@taiga-ui/legacy'; import mask from './mask'; @Component({ selector: 'textarea-doc-example-1', imports: [FormsModule, MaskitoDirective, TuiTextareaModule], template: ` Enter address `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class TextareaDocExample1 { protected readonly mask = mask; protected value = ''; } ================================================ FILE: projects/demo/src/pages/recipes/textarea/examples/1-latin/mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; export default {mask: /^[a-z1-9\s.,/]+$/i} satisfies MaskitoOptions; ================================================ FILE: projects/demo/src/pages/recipes/textarea/examples/maskito-with-textarea.md ================================================ ```ts import {Maskito} from '@maskito/core'; const element: HTMLTextAreaElement = document.querySelector('textarea')!; const maskedTextarea = new Maskito(element, { mask: /^[a-z\s]+$/i, }); // Call it when the element is detached from DOM maskedTextarea.destroy(); ``` ================================================ FILE: projects/demo/src/pages/recipes/textarea/textarea-doc.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {RouterLink} from '@angular/router'; import {DemoPath, DocExamplePrimaryTab} from '@demo/constants'; import {TuiAddonDoc, type TuiRawLoaderContent} from '@taiga-ui/addon-doc'; import {TuiLink} from '@taiga-ui/core'; import {TextareaDocExample1} from './examples/1-latin/component'; @Component({ selector: 'textarea-doc', imports: [RouterLink, TextareaDocExample1, TuiAddonDoc, TuiLink], templateUrl: './textarea-doc.template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export default class TextareaDocComponent { protected readonly coreConceptsOverviewDocPage = `/${DemoPath.CoreConceptsOverview}`; protected readonly maskitoWithTextareaDemo = import('./examples/maskito-with-textarea.md'); protected readonly textareaExample1: Record = { [DocExamplePrimaryTab.MaskitoOptions]: import('./examples/1-latin/mask.ts?raw', { with: {loader: 'text'}, }), }; } ================================================ FILE: projects/demo/src/pages/recipes/textarea/textarea-doc.template.html ================================================

You can use Maskito with HTMLTextAreaElement too. API is the same as for HTMLInputElement .

Learn more in the "Core Concepts" section.

================================================ FILE: projects/demo/src/pages/stackblitz/components/stackblitz-edit-button/stackblitz-edit-button.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {TuiButton} from '@taiga-ui/core'; @Component({ selector: 'stackblitz-edit-button', imports: [TuiButton], template: ` `, styleUrl: './stackblitz-edit-button.style.less', changeDetection: ChangeDetectionStrategy.OnPush, }) export class StackblitzEditButtonComponent {} ================================================ FILE: projects/demo/src/pages/stackblitz/components/stackblitz-edit-button/stackblitz-edit-button.style.less ================================================ @import '@taiga-ui/core/styles/taiga-ui-local.less'; button { @media @tui-mobile { font-size: 0; margin-inline-end: -1rem; } } ================================================ FILE: projects/demo/src/pages/stackblitz/components/stackblitz-starter/stackblitz-starter.component.ts ================================================ import {isPlatformBrowser} from '@angular/common'; import { ChangeDetectionStrategy, Component, inject, type OnInit, PLATFORM_ID, } from '@angular/core'; import {tuiRawLoad, tuiTryParseMarkdownCodeBlock} from '@taiga-ui/addon-doc'; import {TuiLoader} from '@taiga-ui/core'; import {StackblitzService} from '../../stackblitz.service'; @Component({ selector: 'stackblitz-starter', imports: [TuiLoader], template: ` `, styleUrl: './stackblitz-starter.style.less', changeDetection: ChangeDetectionStrategy.OnPush, providers: [StackblitzService], }) export class StackblitzStarterComponent implements OnInit { private readonly platformId = inject(PLATFORM_ID); private readonly stackblitz = inject(StackblitzService); public ngOnInit(): void { if (isPlatformBrowser(this.platformId)) { void this.openStackblitz(); } } protected async openStackblitz(): Promise { const [ts = '', css = ''] = await Promise.all( [import('../../files/starter.ts.md'), import('../../files/styles.css')].map( tuiRawLoad, ), ); return this.stackblitz.openStarter( { title: 'Maskito Starter', description: 'A starter with Maskito library\nDocumentation: https://maskito.dev', files: { 'index.html': '', 'index.ts': tuiTryParseMarkdownCodeBlock(ts)[0]!, 'styles.css': css, }, }, { newWindow: false, openFile: 'index.ts', hideExplorer: true, }, ); } } ================================================ FILE: projects/demo/src/pages/stackblitz/components/stackblitz-starter/stackblitz-starter.style.less ================================================ @import '@taiga-ui/core/styles/taiga-ui-local.less'; .loader { .fullsize(fixed); z-index: 1; background: var(--tui-background-base); } ================================================ FILE: projects/demo/src/pages/stackblitz/files/example.ts.md ================================================ ```ts import './styles.css'; import type {MaskitoElement} from '@maskito/core'; import {Maskito} from '@maskito/core'; import options from './mask'; const element: MaskitoElement = document.querySelector('input,textarea')!; const mask = new Maskito(element, options); console.info('Call this function when the element is detached from DOM', mask.destroy); ``` ================================================ FILE: projects/demo/src/pages/stackblitz/files/starter.ts.md ================================================ ```ts import './styles.css'; import type {MaskitoElement, MaskitoOptions} from '@maskito/core'; import {Maskito} from '@maskito/core'; const options: MaskitoOptions = { mask: /^\d+$/, }; const element: MaskitoElement = document.querySelector('input,textarea')!; const mask = new Maskito(element, options); console.info('Call this function when the element is detached from DOM', mask.destroy); ``` ================================================ FILE: projects/demo/src/pages/stackblitz/files/styles.css ================================================ :root { --tui-text-primary: rgb(27, 31, 59); --tui-text-tertiary: rgba(27, 31, 59, 0.4); --tui-radius-m: 0.75rem; --tui-background-base: #fff; --tui-background-neutral-1-hover: #ededed; --tui-background-accent-1: #526ed3; } input, textarea { display: block; inline-size: 100%; max-inline-size: 25rem; font-size: 0.9375rem; font-family: 'Roboto', sans-serif; border-radius: var(--tui-radius-m); box-sizing: border-box; border: 1px solid var(--tui-background-neutral-1-hover); color: var(--tui-text-primary); background: var(--tui-background-base); outline: none; transition: box-shadow, background, 0.2s ease-in-out; box-shadow: 0 0.125rem 0.1875rem rgba(0, 0, 0, 0.1); } input { min-block-size: 2.75rem; padding: 0 1rem; } textarea { min-block-size: 10rem; padding: 0.5rem; } input:focus::placeholder, textarea:focus::placeholder { color: var(--tui-text-tertiary); } input:hover, textarea:hover { box-shadow: 0 0.125rem 0.3125rem rgba(0, 0, 0, 0.16); } input:focus, textarea:focus { box-shadow: none; border-color: var(--tui-background-accent-1); border-width: 0.125rem; } ================================================ FILE: projects/demo/src/pages/stackblitz/index.ts ================================================ export * from './components/stackblitz-edit-button/stackblitz-edit-button.component'; export * from './components/stackblitz-starter/stackblitz-starter.component'; export * from './stackblitz.service'; ================================================ FILE: projects/demo/src/pages/stackblitz/stackblitz.service.ts ================================================ import {Injectable} from '@angular/core'; import {DocExamplePrimaryTab} from '@demo/constants'; import phonePackageJson from '@maskito/phone/package.json'; import stackblitz, {type OpenOptions, type Project} from '@stackblitz/sdk'; import { type TuiCodeEditor, tuiRawLoad, tuiTryParseMarkdownCodeBlock, } from '@taiga-ui/addon-doc'; import {PolymorpheusComponent} from '@taiga-ui/polymorpheus'; import {StackblitzEditButtonComponent} from './components/stackblitz-edit-button/stackblitz-edit-button.component'; @Injectable() export class StackblitzService implements TuiCodeEditor { private readonly baseProjectConfigs: Pick = { template: 'typescript', dependencies: { '@maskito/core': 'latest', '@maskito/kit': 'latest', '@maskito/phone': 'latest', 'libphonenumber-js': phonePackageJson.peerDependencies['libphonenumber-js'], }, }; public readonly name = 'Stackblitz'; public readonly content = new PolymorpheusComponent(StackblitzEditButtonComponent); public async edit( component: string, id: string, files: Record, ): Promise { const [tsMd = '', css = ''] = await Promise.all( [import('./files/example.ts.md'), import('./files/styles.css')].map( tuiRawLoad, ), ); return stackblitz.openProject( { ...this.baseProjectConfigs, title: `maskito/${component}/${id}`, description: `Maskito example of the component ${component}`, files: { 'index.html': component.includes('textarea') ? '' : '', 'styles.css': css, 'index.ts': tuiTryParseMarkdownCodeBlock(tsMd)[0] ?? '', 'mask.ts': files[DocExamplePrimaryTab.MaskitoOptions] ?? '', }, }, {openFile: 'index.ts,mask.ts'}, ); } public openStarter( {title, description, files}: Pick, openOptions?: OpenOptions, ): void { return stackblitz.openProject( { ...this.baseProjectConfigs, title, description, files, }, openOptions, ); } } ================================================ FILE: projects/demo/src/server.ts ================================================ import {dirname, resolve} from 'node:path'; import {fileURLToPath} from 'node:url'; import { AngularNodeAppEngine, createNodeRequestHandler, isMainModule, writeResponseToNodeResponse, } from '@angular/ssr/node'; import express from 'express'; const serverDistFolder = dirname(fileURLToPath(import.meta.url)); const browserDistFolder = resolve(serverDistFolder, '../browser'); const app = express(); const angularApp = new AngularNodeAppEngine({ // https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf allowedHosts: ['localhost'], }); /** * Serve static files from /browser */ app.use( express.static(browserDistFolder, { maxAge: '1y', index: false, redirect: false, }), ); /** * Handle all other requests by rendering the Angular application. */ app.use((req, res, next) => { angularApp .handle(req) .then(async (response) => response ? writeResponseToNodeResponse(response, res) : next(), ) .catch(next); }); /** * Start the server if this module is the main entry point. * The server listens on the port defined by the `PORT` environment variable, or defaults to 4000. */ if (isMainModule(import.meta.url)) { const port = Number(process.env['PORT'] ?? 4000); app.listen(port, () => { console.info(`Node Express server listening on http://localhost:${port}`); }); } /** * Request handler used by the Angular CLI (for dev-server and during build) */ export const reqHandler = createNodeRequestHandler(app); ================================================ FILE: projects/demo/src/styles.less ================================================ @import '@taiga-ui/core/styles/taiga-ui-local.less'; body { margin: 0; } html, body { scroll-behavior: var(--tui-scroll-behavior); block-size: 100%; @media (prefers-reduced-motion) { scroll-behavior: auto; } } html { color-scheme: light; } [tuiTheme='dark'] { color-scheme: dark; } markdown { li li { // nested list color: var(--tui-text-secondary); &::before { content: '\2014'; inset-inline-start: 0; inset-block-start: auto; inline-size: auto; block-size: auto; background-color: transparent; } } } ================================================ FILE: projects/demo/src/test-setup.ts ================================================ import '@taiga-ui/testing/setup-jest'; import {enableProdMode} from '@angular/core'; /** * - https://github.com/angular/angular/issues/54096 * - https://github.com/thymikee/jest-preset-angular/issues/2582 */ enableProdMode(); ================================================ FILE: projects/demo/src/typings.d.ts ================================================ declare module '*.md' { const content: string; export default content; } declare module '*.html' { const content: string; export default content; } declare module '*.less' { const content: string; export default content; } declare module '*.css' { const content: string; export default content; } /** * At this time, TypeScript does not support type definitions that are based on import attribute values. * We cannot import Typescript files as raw text without @ts-ignore. * This is a workaround to do it without @ts-ignore * (adding ?raw postfix takes less space than @ts-ignore comment above). */ declare module '*.ts?raw' { const content: string; export default content; } declare module '*.tsx?raw' { const content: string; export default content; } ================================================ FILE: projects/demo/tsconfig.app.json ================================================ { "extends": "../../tsconfig.build.json", "compilerOptions": { "allowJs": false, "outDir": "../out-tsc/app", "target": "ES2022", "useDefineForClassFields": false }, "angularCompilerOptions": { "compilationMode": "full" } } ================================================ FILE: projects/demo/tsconfig.typecheck.json ================================================ { "extends": "./tsconfig.app.json", "compilerOptions": { "paths": { "@demo/constants": ["projects/demo/src/app/constants/index.ts"], "@maskito/angular": ["dist/angular"], "@maskito/core": ["dist/core"], "@maskito/kit": ["dist/kit"], "@maskito/phone": ["dist/phone"], "@maskito/react": ["dist/react"], "@maskito/vue": ["dist/vue"] } } } ================================================ FILE: projects/demo-integrations/cypress-react.config.ts ================================================ import {nxComponentTestingPreset} from '@nx/angular/plugins/component-testing'; import {defineConfig} from 'cypress'; export default defineConfig({ component: { ...nxComponentTestingPreset(__filename), indexHtmlFile: 'src/support/component-index.html', supportFile: 'src/support/component-react.ts', specPattern: 'src/tests/component-testing/react/**/*.cy.tsx', devServer: { framework: 'react', bundler: 'vite', }, }, }); ================================================ FILE: projects/demo-integrations/cypress.config.ts ================================================ import {nxComponentTestingPreset} from '@nx/angular/plugins/component-testing'; import {nxE2EPreset} from '@nx/cypress/plugins/cypress-preset'; import {defineConfig} from 'cypress'; export const CYPRESS_CONFIG: Cypress.ConfigOptions = { video: false, fixturesFolder: 'src/fixtures', viewportWidth: 500, viewportHeight: 900, responseTimeout: 60000, pageLoadTimeout: 120000, defaultCommandTimeout: 10000, e2e: { ...nxE2EPreset(__filename, {cypressDir: 'src'}), // We've imported your old cypress plugins here. // You may want to clean this up later by importing these. setupNodeEvents(on, config) { return require('./src/plugins/index.js')(on, config); }, baseUrl: 'http://localhost:3333', specPattern: 'src/tests/!(component-testing)/**/*.cy.ts', }, component: { ...nxComponentTestingPreset(__filename), supportFile: 'src/support/component.ts', indexHtmlFile: 'src/support/component-index.html', specPattern: 'src/tests/component-testing/**/*.cy.ts', // No need to recompile empty sandbox before each test spec justInTimeCompile: false, }, }; export default defineConfig(CYPRESS_CONFIG); ================================================ FILE: projects/demo-integrations/package.json ================================================ { "name": "@maskito/demo-cypress", "private": true, "devDependencies": { "@nx/cypress": "21.6.3", "@nx/vite": "21.6.3", "@vitejs/plugin-react": "4.7.0", "cypress": "14.5.4", "cypress-real-events": "1.15.0" } } ================================================ FILE: projects/demo-integrations/project.json ================================================ { "$schema": "../../node_modules/nx/schemas/project-schema.json", "name": "demo-integrations", "implicitDependencies": ["demo"], "projectType": "application", "sourceRoot": "projects/demo-integrations/src", "targets": { "component-test": { "executor": "@nx/cypress:cypress", "options": { "cypressConfig": "{projectRoot}/cypress.config.ts", "devServerTarget": "demo:build", "skipServe": true, "testingType": "component" } }, "ct-react": { "executor": "@nx/cypress:cypress", "options": { "cypressConfig": "{projectRoot}/cypress-react.config.ts", "skipServe": true, "testingType": "component" } }, "e2e": { "executor": "@nx/cypress:cypress", "options": { "browser": "chrome", "cypressConfig": "{projectRoot}/cypress.config.ts", "devServerTarget": "demo:serve:development", "skipServe": true, "testingType": "e2e" } } } } ================================================ FILE: projects/demo-integrations/src/fixtures/example.json ================================================ {} ================================================ FILE: projects/demo-integrations/src/plugins/index.js ================================================ const {CYPRESS_CONFIG} = require('../../cypress.config'); module.exports = (on) => { on('before:browser:launch', (browser, launchOptions) => { if (browser.name === 'chrome') { launchOptions.args.push( `--window-size=${CYPRESS_CONFIG.viewportWidth},${CYPRESS_CONFIG.viewportHeight}`, '--force-device-scale-factor=2', '--high-dpi-support=1', '--disable-dev-shm-usage', '--incognito', ); } if (browser.isHeadless) { launchOptions.args.push('--disable-gpu'); } return launchOptions; }); }; ================================================ FILE: projects/demo-integrations/src/support/assertions/have-ng-control-value.ts ================================================ /// /// /** * Check if element has Angular form control with specified value * * @note WARNING! This assertion uses `window.ng` which works only if application was build with `"optimization": false` * @example * cy.get('tui-input').should('have.ngControlValue', '123') * */ export const haveNgControlValueAssertion: Chai.ChaiPlugin = (_chai) => { chai.Assertion.addMethod('ngControlValue', function (expectedValue: string) { const subject = this._obj[0]; const windowRef = Cypress.dom.getWindowByElement(subject); // @ts-ignore const angularTools = windowRef.ng; const control = angularTools.getComponent(subject)?.control ?? angularTools.getDirectives(subject).find((x: unknown) => { const inputs = angularTools.getDirectiveMetadata(x).inputs; return 'formControl' in inputs || 'ngModel' in inputs; }); this.assert( angularTools && control.value === expectedValue, `expected #{this} to have Angular form control with value ${expectedValue}`, `expected #{this} to do not have Angular form control with value ${expectedValue}`, subject, ); }); }; ================================================ FILE: projects/demo-integrations/src/support/assertions/index.ts ================================================ import {haveNgControlValueAssertion} from './have-ng-control-value'; declare global { namespace Cypress { interface Chainer { /** * Assertion that checks if given subject has Angular form control with specified value * * @example * cy.get('tui-input').should('have.ngControlValue', '123') * */ (chainer: 'have.ngControlValue'): Chainable; } } } chai.use(haveNgControlValueAssertion); ================================================ FILE: projects/demo-integrations/src/support/commands/index.ts ================================================ /// import {paste} from './paste'; import {smartTick} from './smart-tick'; declare global { namespace Cypress { interface Chainable { smartTick( durationMs: number, options?: Parameters[2], ): Chainable; paste(value: string): Chainable; } } } Cypress.Commands.add( 'smartTick', {prevSubject: ['optional', 'element', 'window', 'document']}, smartTick, ); Cypress.Commands.add('paste', {prevSubject: 'element'}, paste); ================================================ FILE: projects/demo-integrations/src/support/commands/paste.ts ================================================ /** * Cypress does not have built-in support for pasting from clipboard. * This utility is VERY approximate alternative for it. * * @see https://github.com/cypress-io/cypress/issues/28861 */ export function paste( $subject: T, data: string, ): ReturnType> { const element = Cypress.dom.unwrap($subject)[0] as | HTMLInputElement | HTMLTextAreaElement; const {value, selectionStart, selectionEnd} = element; Cypress.log({ displayName: 'paste', message: data, consoleProps() { return { value, selectionStart, selectionEnd, }; }, }); return cy .document() .invoke('addEventListener', 'beforeinput', cy.stub().as('beforeinput'), { once: true, }) .wrap($subject, {log: false}) .should('be.focused') .trigger('beforeinput', { inputType: 'insertFromPaste', data, log: false, }) .document({log: false}) .get('@beforeinput') .its('lastCall.lastArg.defaultPrevented') .then((prevented) => (prevented ? null : cy.document())) .then((doc) => doc?.execCommand('insertText', false, data)) .wrap($subject, {log: false}); } ================================================ FILE: projects/demo-integrations/src/support/commands/smart-tick.ts ================================================ import type {ComponentFixture} from '@angular/core/testing'; export function smartTick[Cypress.PrevSubject]>( $subject: T, durationMs: number, // ms { frequencyMs = 100, fixture, }: { fixture?: ComponentFixture; frequencyMs?: number; // ms } = {}, ): Cypress.Chainable { const iterations = Math.ceil(durationMs / frequencyMs); const lastIterationMs = durationMs % frequencyMs || frequencyMs; for (let i = 1; i <= iterations; i++) { cy.tick(i === iterations ? lastIterationMs : frequencyMs, {log: false}).then( () => fixture?.detectChanges(), // ensure @Input()-properties are changed ); cy.wait(0, {log: false}); // allow React hooks to process } Cypress.log({ displayName: 'smartTick', message: `${durationMs}ms`, consoleProps() { return { durationMs, frequencyMs, }; }, }); return cy.wrap($subject, {log: false}); } ================================================ FILE: projects/demo-integrations/src/support/component-index.html ================================================ demo-integrations Components App
================================================ FILE: projects/demo-integrations/src/support/component-react.ts ================================================ import './assertions'; import './commands'; ================================================ FILE: projects/demo-integrations/src/support/component.ts ================================================ import './assertions'; import './commands'; import 'cypress-real-events'; // https://github.com/cypress-io/cypress/issues/2839 import {mount} from 'cypress/angular'; declare global { namespace Cypress { // eslint-disable-next-line @typescript-eslint/no-unused-vars interface Chainable { mount: typeof mount; } } } export const stableMount: typeof mount = (...mountArgs) => mount(...mountArgs).then(async (mountResponse) => mountResponse.fixture.whenStable().then(() => mountResponse), ); Cypress.Commands.add('mount', stableMount); ================================================ FILE: projects/demo-integrations/src/support/constants/index.ts ================================================ export * from './real-events-support'; ================================================ FILE: projects/demo-integrations/src/support/constants/real-events-support.ts ================================================ /** * Some tests use `cy.realPress([...])`, to simulate text selection. * But this command is not supported by all browsers. * ___ * @see https://docs.cypress.io/guides/guides/cross-browser-testing#Running-Specific-Tests-by-Browser * @see https://github.com/dmtrKovalenko/cypress-real-events#requirements * @see https://github.com/cypress-io/cypress/issues/2839#issuecomment-867411151 */ export const BROWSER_SUPPORTS_REAL_EVENTS: Cypress.TestConfigOverrides = { browser: '!firefox', }; ================================================ FILE: projects/demo-integrations/src/support/e2e.ts ================================================ /// import 'cypress-real-events'; // https://github.com/cypress-io/cypress/issues/2839 import './assertions'; import './commands'; Cypress.on('window:before:load', (win) => { cy.spy(win.console, 'error'); }); afterEach(() => { cy.window().then((win) => expect(win.console.error).to.have.callCount(0)); }); ================================================ FILE: projects/demo-integrations/src/tests/addons/phone/phone-basic.cy.ts ================================================ import {DemoPath} from '@demo/constants'; import {BROWSER_SUPPORTS_REAL_EVENTS} from '../../../support/constants'; import {repeatKey} from '../../utils'; describe('Phone', () => { describe('Basic', () => { beforeEach(() => { cy.visit(`/${DemoPath.PhonePackage}/API`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); describe('basic typing (1 character per keydown)', () => { const tests = [ // [Typed value, Masked value, caretIndex] ['920', '+7 920', '+7 920'.length], ['920341', '+7 920 341', '+7 920 341'.length], ['92034156', '+7 920 341-56', '+7 920 341-56'.length], ['9203415627', '+7 920 341-56-27', '+7 920 341-56-27'.length], ['92034156274234123', '+7 920 341-56-27', '+7 920 341-56-27'.length], ['9 nd4 e', '+7 94', '+7 94'.length], ] as const; tests.forEach(([typedValue, maskedValue, caretIndex]) => { it(`Type "${typedValue}" => "${maskedValue}"`, () => { cy.get('@input') .type(typedValue) .should('have.value', maskedValue) .should('have.prop', 'selectionStart', caretIndex) .should('have.prop', 'selectionEnd', caretIndex); }); }); }); describe('basic erasing (value = "+7 920 424-11-32"', () => { beforeEach(() => { cy.get('@input').type('9204241132'); }); const tests = [ // [How many times "Backspace"-key was pressed, caretPosition, Masked value] [1, '+7 920 424-11-3'.length, '+7 920 424-11-3'], [2, '+7 920 424-11'.length, '+7 920 424-11'], [3, '+7 920 424-1'.length, '+7 920 424-1'], [4, '+7 920 424'.length, '+7 920 424'], [13, '+7 '.length, '+7 '], ] as const; tests.forEach(([n, caretIndex, maskedValue]) => { it(`Backspace x${n} => "${maskedValue}"`, () => { cy.get('@input') .type('{backspace}'.repeat(n)) .should('have.value', maskedValue) .should('have.prop', 'selectionStart', caretIndex) .should('have.prop', 'selectionEnd', caretIndex); }); }); }); describe('Editing somewhere in the middle of a value (NOT the last character)', () => { beforeEach(() => { cy.get('@input').type('920 424-11-32'); }); it('+7 920 424-1|1-32 => Backspace => +7 920 424-|13-2 => Type "2" => +7 920 424-2|1-32', () => { cy.get('@input') .type('{leftArrow}'.repeat('13-2'.length)) .type('{backspace}') .should('have.value', '+7 920 424-13-2') .should('have.prop', 'selectionStart', '+7 920 424-'.length) .should('have.prop', 'selectionEnd', '+7 920 424-'.length) .type('2') .should('have.value', '+7 920 424-21-32') .should('have.prop', 'selectionStart', '+7 920 424-2'.length) .should('have.prop', 'selectionEnd', '+7 920 424-2'.length); }); it('+7 9|20 424-11-32 => Backspace => +7 2|04241132', () => { cy.get('@input') .type('{leftArrow}'.repeat('20 424-11-32'.length)) .type('{backspace}') .should('have.value', '+7 204241132') .should('have.prop', 'selectionStart', '+7 '.length) .should('have.prop', 'selectionEnd', '+7 '.length); }); }); describe('Text selection', () => { beforeEach(() => { cy.get('@input').type('920 424-11-32'); }); describe( 'Select range and press Backspace', BROWSER_SUPPORTS_REAL_EVENTS, () => { it('+7 920 424-11-32 => Select "+7 920 424-|11|-32" => Backspace => +7 920 424-|32', () => { cy.get('@input') .type('{leftArrow}'.repeat('-32'.length)) .realPress(['Shift', ...repeatKey('ArrowLeft', '11'.length)]); cy.get('@input') .type('{backspace}') .should('have.value', '+7 920 424-32') .should('have.prop', 'selectionStart', '+7 920 424-'.length) .should('have.prop', 'selectionEnd', '+7 920 424-'.length); }); it('+7 920 424-11-32 => Select "+7 920 424-1|1-3|2" => Backspace => +7 920 424-1|2', () => { cy.get('@input') .type('{leftArrow}') .realPress([ 'Shift', ...repeatKey('ArrowLeft', '1-3'.length), ]); cy.get('@input') .type('{backspace}') .should('have.value', '+7 920 424-12') .should('have.prop', 'selectionStart', '+7 920 424-1'.length) .should('have.prop', 'selectionEnd', '+7 920 424-1'.length); }); }, ); describe( 'Select range and type a digit', BROWSER_SUPPORTS_REAL_EVENTS, () => { it('+7 920 424-11-32 => Select "+7 920 424-|11|-32" => Type "5" => +7 920 424-5|3-2', () => { cy.get('@input') .type('{leftArrow}'.repeat('-32'.length)) .realPress(['Shift', ...repeatKey('ArrowLeft', '11'.length)]); cy.get('@input') .type('5') .should('have.value', '+7 920 424-53-2') .should('have.prop', 'selectionStart', '+7 920 424-5'.length) .should('have.prop', 'selectionEnd', '+7 920 424-5'.length); }); it('+7 920 424-11-32 => Select "+7 920 424-1|1-3|2" => Type "5" => +7 920 424-15-|2', () => { cy.get('@input') .type('{leftArrow}') .realPress([ 'Shift', ...repeatKey('ArrowLeft', '1-3'.length), ]); cy.get('@input') .type('5') .should('have.value', '+7 920 424-15-2') .should( 'have.prop', 'selectionStart', '+7 920 424-15-'.length, ) .should('have.prop', 'selectionEnd', '+7 920 424-15-'.length); }); }, ); }); describe('Non-removable country prefix', () => { it('cannot be removed via selectAll + Backspace', () => { cy.get('@input') .type('9123456789') .type('{selectall}{backspace}') .should('have.value', '+7 ') .should('have.prop', 'selectionStart', '+7 '.length) .should('have.prop', 'selectionEnd', '+7 '.length); }); it('cannot be removed via selectAll + Delete', () => { cy.get('@input') .type('9123456789') .type('{selectall}{del}') .should('have.value', '+7 ') .should('have.prop', 'selectionStart', '+7 '.length) .should('have.prop', 'selectionEnd', '+7 '.length); }); it('cannot be removed via Backspace', () => { cy.get('@input') .type('9123456789') .type('{backspace}'.repeat('+7 912 345-89'.length)) .should('have.value', '+7 ') .should('have.prop', 'selectionStart', '+7 '.length) .should('have.prop', 'selectionEnd', '+7 '.length); }); it('cannot be removed via Delete', () => { cy.get('@input') .type('9123456789') .type('{moveToStart}') .type('{del}'.repeat('+7 912 345-89'.length)) .should('have.value', '+7 ') .should('have.prop', 'selectionStart', '+7 '.length) .should('have.prop', 'selectionEnd', '+7 '.length); }); it('appears on focus if input is empty', () => { cy.get('@input') .blur() .should('have.value', '') .focus() .should('have.value', '+7 ') .should('have.prop', 'selectionStart', '+7 '.length) .should('have.prop', 'selectionEnd', '+7 '.length); }); it('disappears on blur if there are no more digits except it', () => { cy.get('@input') .focus() .should('have.value', '+7 ') .blur() .should('have.value', ''); }); describe('with caret guard', () => { it('forbids to put caret before country prefix', () => { cy.get('@input') .should('have.value', '+7 ') .should('have.prop', 'selectionStart', '+7 '.length) .should('have.prop', 'selectionEnd', '+7 '.length) .type('{moveToStart}') .should('have.value', '+7 ') .should('have.prop', 'selectionStart', '+7 '.length) .should('have.prop', 'selectionEnd', '+7 '.length) .type('{leftArrow}'.repeat(5)) .should('have.value', '+7 ') .should('have.prop', 'selectionStart', '+7 '.length) .should('have.prop', 'selectionEnd', '+7 '.length); }); it('can be selected via selectAll', () => { cy.get('@input') .type('9123456789') .type('{selectall}') .should('have.value', '+7 912 345-67-89') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', '+7 912 345-67-89'.length); }); }); }); }); describe('Some countries', () => { it('US: +1 212 343-3355', () => { openCountry('US'); cy.get('@input').type('2123433355'); cy.get('@input').should('have.value', '+1 212 343-3355'); }); it('KZ: +7 771 931-1111', () => { openCountry('KZ'); cy.get('@input').type('7719311111'); cy.get('@input').should('have.value', '+7 771 931-1111'); }); it('BY: +375 44 748-82-69', () => { openCountry('BY'); cy.get('@input').type('447488269'); cy.get('@input').should('have.value', '+375 44 748-82-69'); }); it('TR: +90 539 377-07-43', () => { openCountry('TR'); cy.get('@input').type('5393770743'); cy.get('@input').should('have.value', '+90 539 377-07-43'); }); }); }); function openCountry(code: string): void { cy.visit(`/${DemoPath.PhonePackage}/API?countryIsoCode=${code}`); cy.get('#demo-content input').should('be.visible').first().focus().as('input'); } ================================================ FILE: projects/demo-integrations/src/tests/addons/phone/phone-national-trunk-prefix.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Phone | Replaces national trunk prefix by international one on paste', () => { describe('AU | 0 as national trunk prefix', () => { // https://en.wikipedia.org/wiki/Trunk_prefix#Example it('[strict=false] Paste 0733333333 => +61 7 3333-3333', () => { cy.visit(`/${DemoPath.PhonePackage}/API?countryIsoCode=AU&strict=false`); cy.get('#demo-content input:first-of-type') .focus() .should('have.value', '') .paste('0733333333') .should('have.value', '+61 7 3333-3333'); }); it('[strict=true] Paste 0733333333 => +61 7 3333-3333', () => { cy.visit(`/${DemoPath.PhonePackage}/API?countryIsoCode=AU&strict=true`); cy.get('#demo-content input:first-of-type') .focus() .should('have.value', '+61 ') .paste('0733333333') .should('have.value', '+61 7 3333-3333'); }); }); describe('RU | 8 as national trunk prefix', () => { it('[strict=false] Paste 89123456789 => +7 912 345-67-89', () => { cy.visit(`/${DemoPath.PhonePackage}/API?countryIsoCode=RU&strict=false`); cy.get('#demo-content input:first-of-type') .focus() .should('have.value', '') .paste('89123456789') .should('have.value', '+7 912 345-67-89'); }); it('[strict=true] Paste 89123456789 => +7 912 345-67-89', () => { cy.visit(`/${DemoPath.PhonePackage}/API?countryIsoCode=RU&strict=true`); cy.get('#demo-content input:first-of-type') .focus() .should('have.value', '+7 ') .paste('89123456789') .should('have.value', '+7 912 345-67-89'); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/addons/phone/phone-non-strict.cy.ts ================================================ import {DemoPath} from '@demo/constants'; import {BROWSER_SUPPORTS_REAL_EVENTS} from '../../../support/constants'; import {repeatKey} from '../../utils'; describe('Phone', () => { describe('Non-strict', () => { beforeEach(() => { cy.visit(`/${DemoPath.PhonePackage}/API?strict=false`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); describe('basic typing (1 character per keydown)', () => { const tests = [ // [Typed value, Masked value, caretIndex] ['7920', '+7 920', '+7 920'.length], ['7920341', '+7 920 341', '+7 920 341'.length], ['792034156', '+7 920 341-56', '+7 920 341-56'.length], ['79203415627', '+7 920 341-56-27', '+7 920 341-56-27'.length], ['792034156274234123', '+7 920 341-56-27', '+7 920 341-56-27'.length], ['79 nd4 e', '+7 94', '+7 94'.length], ] as const; tests.forEach(([typedValue, maskedValue, caretIndex]) => { it(`Type "${typedValue}" => "${maskedValue}"`, () => { cy.get('@input') .type(typedValue) .should('have.value', maskedValue) .should('have.prop', 'selectionStart', caretIndex) .should('have.prop', 'selectionEnd', caretIndex); }); }); }); describe('basic erasing (value = "+7 920 424-11-32"', () => { beforeEach(() => { cy.get('@input').type('+79204241132'); }); const tests = [ // [How many times "Backspace"-key was pressed, caretPosition, Masked value] [1, '+7 920 424-11-3'.length, '+7 920 424-11-3'], [2, '+7 920 424-11'.length, '+7 920 424-11'], [3, '+7 920 424-1'.length, '+7 920 424-1'], [4, '+7 920 424'.length, '+7 920 424'], [13, ''.length, ''], ] as const; tests.forEach(([n, caretIndex, maskedValue]) => { it(`Backspace x${n} => "${maskedValue}"`, () => { cy.get('@input') .type('{backspace}'.repeat(n)) .should('have.value', maskedValue) .should('have.prop', 'selectionStart', caretIndex) .should('have.prop', 'selectionEnd', caretIndex); }); }); }); describe('Editing somewhere in the middle of a value (NOT the last character)', () => { beforeEach(() => { cy.get('@input').type('+7 920 424-11-32'); }); it('+7 920 424-1|1-32 => Backspace => +7 920 424-|13-2 => Type "2" => +7 920 424-2|1-32', () => { cy.get('@input') .type('{leftArrow}'.repeat('13-2'.length)) .type('{backspace}') .should('have.value', '+7 920 424-13-2') .should('have.prop', 'selectionStart', '+7 920 424-'.length) .should('have.prop', 'selectionEnd', '+7 920 424-'.length) .type('2') .should('have.value', '+7 920 424-21-32') .should('have.prop', 'selectionStart', '+7 920 424-2'.length) .should('have.prop', 'selectionEnd', '+7 920 424-2'.length); }); it('+7 9|20 424-11-32 => Backspace => +7 2|04241132', () => { cy.get('@input') .type('{leftArrow}'.repeat('20 424-11-32'.length)) .type('{backspace}') .should('have.value', '+7 204241132') .should('have.prop', 'selectionStart', '+7 '.length) .should('have.prop', 'selectionEnd', '+7 '.length); }); }); describe('Text selection', () => { beforeEach(() => { cy.get('@input').type('+7 920 424-11-32'); }); describe( 'Select range and press Backspace', BROWSER_SUPPORTS_REAL_EVENTS, () => { it('+7 920 424-11-32 => Select "+7 920 424-|11|-32" => Backspace => +7 920 424-|32', () => { cy.get('@input') .type('{leftArrow}'.repeat('-32'.length)) .realPress(['Shift', ...repeatKey('ArrowLeft', '11'.length)]); cy.get('@input') .type('{backspace}') .should('have.value', '+7 920 424-32') .should('have.prop', 'selectionStart', '+7 920 424-'.length) .should('have.prop', 'selectionEnd', '+7 920 424-'.length); }); it('+7 920 424-11-32 => Select "+7 920 424-1|1-3|2" => Backspace => +7 920 424-1|2', () => { cy.get('@input') .type('{leftArrow}') .realPress([ 'Shift', ...repeatKey('ArrowLeft', '1-3'.length), ]); cy.get('@input') .type('{backspace}') .should('have.value', '+7 920 424-12') .should('have.prop', 'selectionStart', '+7 920 424-1'.length) .should('have.prop', 'selectionEnd', '+7 920 424-1'.length); }); }, ); describe( 'Select range and type a digit', BROWSER_SUPPORTS_REAL_EVENTS, () => { it('+7 920 424-11-32 => Select "+7 920 424-|11|-32" => Type "5" => +7 920 424-5|3-2', () => { cy.get('@input') .type('{leftArrow}'.repeat('-32'.length)) .realPress(['Shift', ...repeatKey('ArrowLeft', '11'.length)]); cy.get('@input') .type('5') .should('have.value', '+7 920 424-53-2') .should('have.prop', 'selectionStart', '+7 920 424-5'.length) .should('have.prop', 'selectionEnd', '+7 920 424-5'.length); }); it('+7 920 424-11-32 => Select "+7 920 424-1|1-3|2" => Type "5" => +7 920 424-15-|2', () => { cy.get('@input') .type('{leftArrow}') .realPress([ 'Shift', ...repeatKey('ArrowLeft', '1-3'.length), ]); cy.get('@input') .type('5') .should('have.value', '+7 920 424-15-2') .should( 'have.prop', 'selectionStart', '+7 920 424-15-'.length, ) .should('have.prop', 'selectionEnd', '+7 920 424-15-'.length); }); }, ); }); describe('Pasting numbers', () => { it('should not cut the last digit when pasting', () => { cy.get('@input') .paste('12125552365') .should('have.value', '+1 212 555-2365'); }); it('should merge pasted numbers with existing input', () => { cy.get('@input') .clear() .type('+6488') .paste('85584567') .should('have.value', '+64 888 558-4567'); }); }); }); describe('Some countries', () => { it('US: +1 212 343-3355', () => { openCountry('US'); cy.get('@input').type('12123433355'); cy.get('@input').should('have.value', '+1 212 343-3355'); }); it('KZ: +7 771 931-1111', () => { openCountry('KZ'); cy.get('@input').type('77719311111'); cy.get('@input').should('have.value', '+7 771 931-1111'); }); it('BY: +375 44 748-82-69', () => { openCountry('BY'); cy.get('@input').type('375447488269'); cy.get('@input').should('have.value', '+375 44 748-82-69'); }); it('TR: +90 539 377-07-43', () => { openCountry('TR'); cy.get('@input').type('905393770743'); cy.get('@input').should('have.value', '+90 539 377-07-43'); }); }); }); function openCountry(code: string): void { cy.visit(`/${DemoPath.PhonePackage}/API?strict=false&countryIsoCode=${code}`); cy.get('#demo-content input').should('be.visible').first().focus().as('input'); } ================================================ FILE: projects/demo-integrations/src/tests/addons/phone/phone-separator.cy.ts ================================================ import {DemoPath} from '@demo/constants'; import {BROWSER_SUPPORTS_REAL_EVENTS} from '../../../support/constants'; import {repeatKey} from '../../utils'; describe('Phone', () => { describe('Separator', () => { beforeEach(() => { cy.visit(`/${DemoPath.PhonePackage}/API?separator$=1`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); describe('basic typing (1 character per keydown)', () => { const tests = [ // [Typed value, Masked value, caretIndex] ['920', '+7 920', '+7 920'.length], ['920341', '+7 920 341', '+7 920 341'.length], ['92034156', '+7 920 341 56', '+7 920 341 56'.length], ['9203415627', '+7 920 341 56 27', '+7 920 341 56 27'.length], ['92034156274234123', '+7 920 341 56 27', '+7 920 341 56 27'.length], ['9 nd4 e', '+7 94', '+7 94'.length], ] as const; tests.forEach(([typedValue, maskedValue, caretIndex]) => { it(`Type "${typedValue}" => "${maskedValue}"`, () => { cy.get('@input') .type(typedValue) .should('have.value', maskedValue) .should('have.prop', 'selectionStart', caretIndex) .should('have.prop', 'selectionEnd', caretIndex); }); }); }); describe('basic erasing (value = "+7 920 424 11 32"', () => { beforeEach(() => { cy.get('@input').type('9204241132'); }); const tests = [ // [How many times "Backspace"-key was pressed, caretPosition, Masked value] [1, '+7 920 424 11 3'.length, '+7 920 424 11 3'], [2, '+7 920 424 11'.length, '+7 920 424 11'], [3, '+7 920 424 1'.length, '+7 920 424 1'], [4, '+7 920 424'.length, '+7 920 424'], [13, '+7 '.length, '+7 '], ] as const; tests.forEach(([n, caretIndex, maskedValue]) => { it(`Backspace x${n} => "${maskedValue}"`, () => { cy.get('@input') .type('{backspace}'.repeat(n)) .should('have.value', maskedValue) .should('have.prop', 'selectionStart', caretIndex) .should('have.prop', 'selectionEnd', caretIndex); }); }); }); describe('Editing somewhere in the middle of a value (NOT the last character)', () => { beforeEach(() => { cy.get('@input').type('920 424 11 32'); }); it('+7 9|20 424 11 32 => Backspace => +7 2|04241132', () => { cy.get('@input') .type('{leftArrow}'.repeat('20 424 11 32'.length)) .type('{backspace}') .should('have.value', '+7 204241132') .should('have.prop', 'selectionStart', '+7 '.length) .should('have.prop', 'selectionEnd', '+7 '.length); }); }); describe('Text selection', () => { beforeEach(() => { cy.get('@input').type('920 424 11 32'); }); describe( 'Select range and press Backspace', BROWSER_SUPPORTS_REAL_EVENTS, () => { it('+7 920 424-11-32 => Select "+7 920 424 |11| 32" => Backspace => +7 920 424 |32', () => { cy.get('@input') .type('{leftArrow}'.repeat(' 32'.length)) .realPress(['Shift', ...repeatKey('ArrowLeft', '11'.length)]); cy.get('@input') .type('{backspace}') .should('have.value', '+7 920 424 32') .should('have.prop', 'selectionStart', '+7 920 424 '.length) .should('have.prop', 'selectionEnd', '+7 920 424 '.length); }); }, ); describe( 'Select range and type a digit', BROWSER_SUPPORTS_REAL_EVENTS, () => { it('+7 920 424-11-32 => Select "+7 920 424 |11| 32" => Type "5" => +7 920 424 5|3 2', () => { cy.get('@input') .type('{leftArrow}'.repeat(' 32'.length)) .realPress(['Shift', ...repeatKey('ArrowLeft', '11'.length)]); cy.get('@input') .type('5') .should('have.value', '+7 920 424 53 2') .should('have.prop', 'selectionStart', '+7 920 424 5'.length) .should('have.prop', 'selectionEnd', '+7 920 424 5'.length); }); }, ); }); }); describe('Some countries', () => { it('US: +1 212 343 3355', () => { openCountry('US'); cy.get('@input').type('2123433355'); cy.get('@input').should('have.value', '+1 212 343 3355'); }); it('KZ: +7 771 931 1111', () => { openCountry('KZ'); cy.get('@input').type('7719311111'); cy.get('@input').should('have.value', '+7 771 931 1111'); }); it('BY: +375 44 748 82 69', () => { openCountry('BY'); cy.get('@input').type('447488269'); cy.get('@input').should('have.value', '+375 44 748 82 69'); }); it('TR: +90 539 377 07 43', () => { openCountry('TR'); cy.get('@input').type('5393770743'); cy.get('@input').should('have.value', '+90 539 377 07 43'); }); }); }); function openCountry(code: string): void { cy.visit(`/${DemoPath.PhonePackage}/API?countryIsoCode=${code}&separator$=1`); cy.get('#demo-content input').should('be.visible').first().focus().as('input'); } ================================================ FILE: projects/demo-integrations/src/tests/addons/phone/phone-strict.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Phone [strict]=true', () => { describe('[countryIsoCode]=KZ', () => { beforeEach(() => { cy.visit(`/${DemoPath.PhonePackage}/API?countryIsoCode=KZ&strict=true`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .should('have.value', '+7 ') .as('input'); }); it('Paste +7 777 (+7 is treated as country prefix => no prefix duplication)', () => { cy.get('@input') .paste('+7 777') .should('have.value', '+7 777') .should('have.prop', 'selectionStart', '+7 777'.length) .should('have.prop', 'selectionEnd', '+7 777'.length); }); it('Paste +7777 (+7 is treated as country prefix => no prefix duplication)', () => { cy.get('@input') .paste('+7777') .should('have.value', '+7 777') .should('have.prop', 'selectionStart', '+7 777'.length) .should('have.prop', 'selectionEnd', '+7 777'.length); }); it('Paste 7777 (no plus sign => all 4 sevens are treated as of incomplete value (+7 is added automatically))', () => { cy.get('@input') .paste('7777') .should('have.value', '+7 777 7') .should('have.prop', 'selectionStart', '+7 777 7'.length) .should('have.prop', 'selectionEnd', '+7 777 7'.length); }); }); describe('[countryIsoCode]=RU', () => { beforeEach(() => { cy.visit(`/${DemoPath.PhonePackage}/API?countryIsoCode=RU&strict=true`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .should('have.value', '+7 ') .as('input'); }); [ '+7 912 345-67-89', '+79123456789', '79123456789', // even without plus sign => no country prefix duplication '89123456789', // 8 should be replaced by +7 automatically '9123456789', ].forEach((value) => { it(`Paste ${value}`, () => { cy.get('@input') .paste(value) .should('have.value', '+7 912 345-67-89') .should('have.prop', 'selectionStart', '+7 912 345-67-89'.length) .should('have.prop', 'selectionEnd', '+7 912 345-67-89'.length); }); }); }); describe('Pasting numbers', () => { beforeEach(() => { cy.visit(`/${DemoPath.PhonePackage}/API?countryIsoCode=RU&strict=true`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .should('have.value', '+7 ') .as('input'); }); it('should merge pasted numbers with existing input', () => { cy.get('@input') .clear() .type('987') .paste('654') .should('have.value', '+7 987 654'); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/angular/form-control-changes.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Angular FormControl and native input have the same values', () => { beforeEach(() => { cy.visit(`/${DemoPath.Time}/API`); cy.get('#demo-content button').should('contain', 'Form value').click(); cy.get('#demo-content tui-input').as('inputWrapper'); cy.get('#demo-content input').should('be.visible').first().focus().as('input'); }); it('on initialization', () => { cy.get('@input').should('have.value', ''); cy.get('@inputWrapper').should('have.ngControlValue', ''); }); it('after input a new character', () => { cy.get('@input').type('{moveToStart}').type('2').should('have.value', '2'); cy.get('@inputWrapper').should('have.ngControlValue', '2'); }); it('after input many new characters', () => { cy.get('@input').type('{moveToStart}').type('2359').should('have.value', '23:59'); cy.get('@inputWrapper').should('have.ngControlValue', '23:59'); }); it('after delete via "Backspace"-button', () => { cy.get('@input') .type('{moveToStart}') .type('2359') .type('{backspace}') .should('have.value', '23:5'); cy.get('@inputWrapper').should('have.ngControlValue', '23:5'); }); it('after delete via "Delete"-button', () => { cy.get('@input') .type('{moveToStart}') .type('2359') .type('{leftArrow}'.repeat(2)) .type('{del}') .should('have.value', '23:09'); cy.get('@inputWrapper').should('have.ngControlValue', '23:09'); }); it('after select all + "Backspace"-button', () => { cy.get('@input') .type('{moveToStart}') .type('2359') .type('{selectall}{backspace}') .should('have.value', ''); cy.get('@inputWrapper').should('have.ngControlValue', ''); }); it('after select all + "Delete"-button', () => { cy.get('@input') .type('{moveToStart}') .type('2359') .type('{selectall}{del}') .should('have.value', ''); cy.get('@inputWrapper').should('have.ngControlValue', ''); }); }); ================================================ FILE: projects/demo-integrations/src/tests/angular/unmask-handler.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Angular | Custom unmask handler', () => { beforeEach(() => { cy.visit(DemoPath.Angular); cy.get('#unmask [automation-id="tui-doc-example"]').as('example'); cy.get('@example').find('input').as('input'); cy.get('@example').find('code').as('controlValue'); cy.get('@example').find('button').as('patch'); }); it('initial textfield value', () => { cy.get('@input').should('have.value', '1.000,42'); }); it('initial control value', () => { cy.get('@input').should('have.ngControlValue', 1_000.42); cy.get('@controlValue').should('have.text', '1000.42'); }); it('textfield value after programmatic control patch', () => { cy.get('@patch').click(); cy.get('@input').should('have.value', '1.234.567,89'); }); it('control value after programmatic control patch', () => { cy.get('@patch').click(); cy.get('@input').should('have.ngControlValue', 1_234_567.89); cy.get('@controlValue').should('have.text', '1234567.89'); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/angular/disable-mask-on-null.cy.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import {TestInput} from '../utils'; describe('@maskito/angular | Disable mask if null is passed as options', () => { describe('type="email" is not compatible with Maskito (it does not have `setSelectionRange`)', () => { it('should throw error if non-nullable options are passed', (done) => { const maskitoOptions: MaskitoOptions = { mask: [/[a-z]/, /[a-z]/, '@', /[a-z]/], }; cy.mount(TestInput, { componentProperties: { type: 'email', maskitoOptions, }, }); cy.on('uncaught:exception', ({message}) => { expect(message).to.include( "Failed to execute 'setSelectionRange' on 'HTMLInputElement': The input element's type ('email') does not support selection", ); // ensure that an uncaught exception was thrown done(); }); cy.get('input').type('a12bc'); }); it('should not throw any error is options are equal to `null`', () => { cy.mount(TestInput, { componentProperties: { type: 'email', maskitoOptions: null, }, }); cy.get('input').type('a12bc').should('have.value', 'a12bc'); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/angular/pattern.cy.ts ================================================ import {MaskitoPattern} from '@maskito/angular'; describe('@maskito/angular | MaskitoPattern', () => { it('set regex over provided MaskitoOptions mask', () => { cy.mount('', { imports: [MaskitoPattern], componentProperties: {pattern: /^\d{0,4}$/}, }); cy.get('input').type('a12bc').should('have.value', '12'); }); it('set regex from string input', () => { cy.mount('', {imports: [MaskitoPattern]}); cy.get('input').type('a12bc34').should('have.value', 'a1234'); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/angular-predicate/angular-predicate.cy.ts ================================================ import {ChangeDetectionStrategy, Component, signal} from '@angular/core'; import type {ComponentFixture} from '@angular/core/testing'; import {MaskitoDirective} from '@maskito/angular'; import type { MaskitoElement, MaskitoElementPredicate, MaskitoOptions, } from '@maskito/core'; import {maskitoNumberOptionsGenerator} from '@maskito/kit'; import {TestInput} from '../utils'; import {MultiTestInputComponent} from './multi-test.component'; describe('@maskito/angular | Predicate', () => { it('can detect run-time changes', () => { cy.mount(MultiTestInputComponent); cy.get('input').should('be.visible').first().as('card'); cy.get('input').should('be.visible').eq(1).as('name'); cy.get('@card') .focus() .type('12341234abcd12341234') .should('have.value', '1234 1234 1234 1234'); cy.get('@name').focus().type('12341234abcd12341234').should('have.value', 'ABCD'); }); it('supports asynchronous predicate', () => { const cardMask: MaskitoOptions = { mask: [ ...Array.from({length: 4}).fill(/\d/), ' ', ...Array.from({length: 4}).fill(/\d/), ' ', ...Array.from({length: 4}).fill(/\d/), ' ', ...Array.from({length: 4}).fill(/\d/), ], }; cy.mount(TestInput, { componentProperties: { maskitoOptions: cardMask, maskitoElementPredicate: async (element: HTMLElement) => Promise.resolve(element as MaskitoElement), }, }); cy.get('input').as('card'); cy.get('@card') .focus() .type('12341234abcd12341234') .should('have.value', '1234 1234 1234 1234'); }); describe('ignores the previous predicate if it resolves after the switching to new one (race condition check)', () => { let fixture!: ComponentFixture; beforeEach(() => { const delay = async (ms: number): Promise => new Promise((resolve) => { setTimeout(resolve, ms); }); const invalidPredicate: MaskitoElementPredicate = async (element) => delay(1_000).then(() => element.querySelectorAll('input')[0]!); const validPredicate: MaskitoElementPredicate = async (element) => delay(1_000).then(() => element.querySelectorAll('input')[1]!); @Component({ imports: [MaskitoDirective], template: `
`, changeDetection: ChangeDetectionStrategy.OnPush, }) class ComplexTextfield { protected maskitoOptions = maskitoNumberOptionsGenerator(); protected elementPredicate = signal(invalidPredicate); constructor() { setTimeout(() => this.elementPredicate.set(validPredicate), 500); } } cy.clock(); cy.mount(ComplexTextfield).then((res) => { fixture = res.fixture; }); cy.get('input').first().as('hidden'); cy.get('input').last().as('real'); }); it('allows to enter letters in both textfield (before any predicate is resolved)', () => { cy.get('@hidden').focus().type('12abc3').should('have.value', '12abc3'); cy.get('@real').focus().type('12abc3').should('have.value', '12abc3'); }); it('allows to enter letters in both textfield (active predicate is changed; both are still resolving)', () => { cy.smartTick(520, {fixture}); cy.get('@hidden').focus().type('12abc3').should('have.value', '12abc3'); cy.get('@real').focus().type('12abc3').should('have.value', '12abc3'); }); it('allows to enter letters in both textfield (invalid predicate was resolved AND SKIPPED; valid is still resolving)', () => { cy.smartTick(520, {fixture}); cy.smartTick(500); // invalid predicate was resolved cy.get('@hidden').focus().type('12abc3').should('have.value', '12abc3'); cy.get('@real').focus().type('12abc3').should('have.value', '12abc3'); }); it('forbids to enter letters only in real textfield (valid and invalid predicates were resolved)', () => { cy.smartTick(520, {fixture}); cy.smartTick(500); // invalid predicate was resolved cy.smartTick(500); // valid predicate was resolved cy.get('@hidden').focus().type('12abc3').should('have.value', '12abc3'); cy.get('@real').focus().type('12abc3').should('have.value', '123'); }); }); describe('[maskitoOptions] are changed before long element predicate is resolved', () => { let fixture!: ComponentFixture; const SWITCH_OPTIONS_TIME = 1_000; const PREDICATE_RESOLVING_TIME = 2_000; beforeEach(() => { @Component({ imports: [MaskitoDirective], template: ` `, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { private readonly numberOptions = {mask: /^\d+$/}; private readonly engLettersOptions = {mask: /^[a-z]+$/i}; protected options = signal(this.numberOptions); constructor() { setTimeout(() => { this.options.set(this.engLettersOptions); }, SWITCH_OPTIONS_TIME); } protected readonly elementPredicate: MaskitoElementPredicate = async ( element, ) => new Promise((resolve) => { setTimeout( () => resolve(element as HTMLInputElement), PREDICATE_RESOLVING_TIME, ); }); } cy.clock(); cy.mount(TestComponent).then((res) => { fixture = res.fixture; }); }); it('can enter any value before no predicate is resolved', () => { cy.get('input').focus().type('12abc3').should('have.value', '12abc3'); }); it('enabling of the first mask should be skipped if [maskitoOptions] were changed during resolving of element predicate', () => { cy.smartTick(PREDICATE_RESOLVING_TIME, {fixture}); // predicate is resolved only once for digit cases cy.get('input').focus().type('12abc3').should('have.value', '12abc3'); }); it('only the last mask should be applied if [maskitoOptions] were changed during resolving of element predicates', () => { cy.smartTick(SWITCH_OPTIONS_TIME + PREDICATE_RESOLVING_TIME, {fixture}); // enough time to resolve element predicated for both cases cy.get('input').focus().type('12abc3').should('have.value', 'abc'); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/angular-predicate/multi-test.component.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import type {MaskitoElementPredicate, MaskitoOptions} from '@maskito/core'; @Component({ selector: 'synchronous-test-input', imports: [FormsModule, MaskitoDirective], template: `
`, changeDetection: ChangeDetectionStrategy.OnPush, }) export class MultiTestInputComponent { protected value = { number: '', name: '', }; protected readonly cardMask: MaskitoOptions = { mask: [ ...Array.from({length: 4}).fill(/\d/), ' ', ...Array.from({length: 4}).fill(/\d/), ' ', ...Array.from({length: 4}).fill(/\d/), ' ', ...Array.from({length: 4}).fill(/\d/), ], }; protected readonly nameMask: MaskitoOptions = { mask: /^[a-z\s]+$/i, postprocessors: [ ({value, selection}) => ({value: value.toUpperCase(), selection}), ], }; protected readonly cardPredicate: MaskitoElementPredicate = (element) => element.querySelectorAll('input')[0]!; protected readonly namePredicate: MaskitoElementPredicate = (element) => element.querySelectorAll('input')[1]!; } ================================================ FILE: projects/demo-integrations/src/tests/component-testing/change-event-plugin/change-event-plugin.cy.ts ================================================ import {maskitoChangeEventPlugin, type MaskitoOptions} from '@maskito/core'; import {maskitoNumberOptionsGenerator} from '@maskito/kit'; import {createOutputSpy} from 'cypress/angular'; import {TestInput} from '../utils'; describe('maskitoChangeEventPlugin', () => { const numberMask = maskitoNumberOptionsGenerator({ thousandSeparator: ' ', decimalSeparator: '.', maximumFractionDigits: 2, }); const maskitoOptions: MaskitoOptions = { ...numberMask, plugins: [...numberMask.plugins, maskitoChangeEventPlugin()], }; beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions, change: createOutputSpy('changeEvent'), }, }); }); it('Enter only valid value (Maskito does not prevent any typed character) => only 1 change event on blur', () => { cy.get('input').type('123').should('have.value', '123'); cy.get('@changeEvent').should('not.be.called'); cy.get('input').blur(); cy.get('@changeEvent').should('have.callCount', 1); }); it('Enter valid value + pseudo decimal separator (Maskito replaces pseudo separator with valid one) => only 1 change event on blur', () => { cy.get('input').type('123,').should('have.value', '123.'); cy.get('@changeEvent').should('not.be.called'); cy.get('input').blur(); cy.get('@changeEvent').should('have.callCount', 1); }); it('Enter only decimal separator (Maskito pads it with zero) => only 1 change event on blur', () => { cy.get('input').type('.').should('have.value', '0.'); cy.get('@changeEvent').should('not.be.called'); cy.get('input').blur(); cy.get('@changeEvent').should('have.callCount', 1); }); it('Enter only invalid value (Maskito rejects all typed characters) => no change event', () => { cy.get('input').type('abc').should('have.value', ''); cy.get('@changeEvent').should('not.be.called'); cy.get('input').blur(); cy.get('@changeEvent').should('not.be.called'); }); it('Enter any value value and then erase it again => no change event', () => { cy.get('input').type('123').should('have.value', '123'); cy.get('@changeEvent').should('not.be.called'); cy.get('input').clear().blur(); cy.get('@changeEvent').should('not.be.called'); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/initial-calibration-plugin/dispatch-event.cy.ts ================================================ import {maskitoInitialCalibrationPlugin, type MaskitoOptions} from '@maskito/core'; import {createOutputSpy} from 'cypress/angular'; import {TestInput} from '../utils'; describe('InitialCalibrationPlugin | count number of dispatched input event', () => { const maskitoOptions: MaskitoOptions = { mask: /^\d+$/, plugins: [maskitoInitialCalibrationPlugin()], }; it('Valid initial value => no dispatch of InputEvent', () => { cy.mount(TestInput, { componentProperties: { initialValue: '123', maskitoOptions, input: createOutputSpy('inputEvent'), }, }); cy.get('input').should('have.value', '123'); cy.get('@inputEvent').should('not.be.called'); }); it('Invalid initial value => dispatch of InputEvent', () => { cy.mount(TestInput, { componentProperties: { initialValue: '4abc56', maskitoOptions, input: createOutputSpy('inputEvent'), }, }); cy.get('input').should('have.value', '456'); cy.get('@inputEvent').should('have.callCount', 1); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/multi-character-date-segment-separator/multi-character-date-segment-separator.cy.ts ================================================ import { maskitoDateOptionsGenerator, maskitoDateRangeOptionsGenerator, maskitoDateTimeOptionsGenerator, } from '@maskito/kit'; import {TestInput} from '../utils'; describe('Multi character date segment separator', () => { const multiCharacterSeparator = '. '; // Slovenia; [ { title: 'Date', maskitoOptions: maskitoDateOptionsGenerator({ mode: 'dd/mm/yyyy', separator: multiCharacterSeparator, }), initialValue: '', }, { title: 'DateRange (1st date)', maskitoOptions: maskitoDateRangeOptionsGenerator({ mode: 'dd/mm/yyyy', dateSeparator: multiCharacterSeparator, }), initialValue: '', }, { title: 'DateRange (2nd date)', maskitoOptions: maskitoDateRangeOptionsGenerator({ mode: 'dd/mm/yyyy', rangeSeparator: '_', dateSeparator: multiCharacterSeparator, }), initialValue: '12. 04. 1961_', }, { title: 'DateTime', maskitoOptions: maskitoDateTimeOptionsGenerator({ dateMode: 'dd/mm/yyyy', timeMode: 'HH:MM', dateSeparator: multiCharacterSeparator, }), initialValue: '', }, ].forEach(({title, maskitoOptions, initialValue}) => { describe(title, () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions, initialValue, }, }); cy.get('input') .focus() .should('have.value', initialValue) .type('{moveToEnd}'); }); it('Type 31121999 => 31. 12. 1999', () => { cy.get('input') .type('31121999') .should('have.value', `${initialValue}31. 12. 1999`); }); it('Type 999 => 09. 09. 9 (zero padding works)', () => { cy.get('input') .type('999') .should('have.value', `${initialValue}09. 09. 9`); }); it('Type 35 => 03.05 (pads every digit with zero to prevent invalid date segments)', () => { cy.get('input') .type('3') .should('have.value', `${initialValue}3`) .type('5') .should('have.value', `${initialValue}03. 05`); }); it('Type 31.15 => 31.1 (prevent to enter impossible month date segment)', () => { cy.get('input').type('3115').should('have.value', `${initialValue}31. 1`); }); describe('Editing somewhere in the middle of a value (NOT the last character)', () => { it('01.1|2.1998 => Backspace => 01.|02.1998 => Type "1" => 01.1|2.1998', () => { cy.get('input') .type('01121998') .type('{leftArrow}'.repeat('2. 1998'.length)) .should( 'have.prop', 'selectionStart', `${initialValue}01. 1`.length, ) .should( 'have.prop', 'selectionEnd', `${initialValue}01. 1`.length, ) .type('{backspace}') .should('have.value', `${initialValue}01. 02. 1998`) .should( 'have.prop', 'selectionStart', `${initialValue}01. `.length, ) .should('have.prop', 'selectionEnd', `${initialValue}01. `.length) .type('1') .should('have.value', `${initialValue}01. 12. 1998`) .should( 'have.prop', 'selectionStart', `${initialValue}01. 1`.length, ) .should( 'have.prop', 'selectionEnd', `${initialValue}01. 1`.length, ); }); it('12|.01.2008 => Backspace => 1|0.01.2008 => Type "1" => 11|.01.2008', () => { cy.get('input') .type('12012008') .type('{leftArrow}'.repeat(' .01 .2008'.length)) .should('have.prop', 'selectionStart', `${initialValue}12`.length) .should('have.prop', 'selectionEnd', `${initialValue}12`.length) .type('{backspace}') .should('have.value', `${initialValue}10. 01. 2008`) .should('have.prop', 'selectionStart', `${initialValue}1`.length) .should('have.prop', 'selectionEnd', `${initialValue}1`.length) .type('1') .should('have.value', `${initialValue}11. 01. 2008`) .should( 'have.prop', 'selectionStart', `${initialValue}11. `.length, ) .should( 'have.prop', 'selectionEnd', `${initialValue}11. `.length, ); }); it('12.|12.2010 => Type "9" => 12.09.|2010', () => { cy.get('input') .type('12122010') .type('{leftArrow}'.repeat('12. 2010'.length)) .should( 'have.prop', 'selectionStart', `${initialValue}12. `.length, ) .should('have.prop', 'selectionEnd', `${initialValue}12. `.length) .type('9') .should('have.value', `${initialValue}12. 09. 2010`) .should( 'have.prop', 'selectionStart', `${initialValue}12. 09. `.length, ) .should( 'have.prop', 'selectionEnd', `${initialValue}12. 09. `.length, ); }); it('|15.01.2012 => Type "3" => 3|0.01.2012', () => { cy.get('input') .type('15012012') .type('{leftArrow}'.repeat('15. 01. 2012'.length)) .should('have.prop', 'selectionStart', initialValue.length) .should('have.prop', 'selectionEnd', initialValue.length) .type('3') .should('have.value', `${initialValue}30. 01. 2012`) .should('have.prop', 'selectionStart', `${initialValue}3`.length) .should('have.prop', 'selectionEnd', `${initialValue}3`.length); }); it('02|.01.2008 => Backspace => 0|1.01.2008 => Type "5" => 05|.01.2008', () => { cy.get('input') .type('02012008') .type('{leftArrow}'.repeat('. 01. 2008'.length)) .should('have.prop', 'selectionStart', `${initialValue}02`.length) .should('have.prop', 'selectionEnd', `${initialValue}02`.length) .type('{backspace}') .should('have.value', `${initialValue}01. 01. 2008`) .should('have.prop', 'selectionStart', `${initialValue}0`.length) .should('have.prop', 'selectionEnd', `${initialValue}0`.length) .type('5') .should('have.value', `${initialValue}05. 01. 2008`) .should( 'have.prop', 'selectionStart', `${initialValue}05. `.length, ) .should( 'have.prop', 'selectionEnd', `${initialValue}05. `.length, ); }); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/native-max-length/native-maxlength-attribute.cy.ts ================================================ import {ChangeDetectionStrategy, Component, ElementRef, inject} from '@angular/core'; import type {MaskitoOptions} from '@maskito/core'; import {maskitoNumberOptionsGenerator} from '@maskito/kit'; import {BROWSER_SUPPORTS_REAL_EVENTS} from '../../../support/constants'; import {TestInput} from '../utils'; describe('Native attribute maxlength works', () => { describe(' & overwriteMode = shift', () => { beforeEach(() => { const maskitoOptions = maskitoNumberOptionsGenerator({ thousandSeparator: ' ', }); cy.mount(TestInput, { componentProperties: { maskitoOptions, maxLength: 3, }, }); cy.get('input[maxlength="3"]') .should('have.prop', 'maxlength', 3) .as('input'); }); it('accepts 2 digits', () => { cy.get('@input').type('12').should('have.value', '12'); }); it('accepts 3 digits', () => { cy.get('@input').type('123').should('have.value', '123'); }); it( 'can replace selected digit by new one (even if length of the value is already equal to maxlength-property)', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input').type('123').realPress(['Shift', 'ArrowLeft']); cy.get('@input').type('0').should('have.value', '120'); }, ); describe('rejects to enter digits more than maxlength-property', () => { it('123| => Type 4 => 123|', () => { cy.get('@input') .type('1234') .should('have.value', '123') .should('have.prop', 'selectionStart', '123'.length) .should('have.prop', 'selectionEnd', '123'.length); }); it('12|3 => Type 0 => 12|3', () => { cy.get('@input') .type('123') .type('{leftArrow}') .type('0') .should('have.value', '123') .should('have.prop', 'selectionStart', '12'.length) .should('have.prop', 'selectionEnd', '12'.length); }); it('1|23 => Type 0 => 1|23', () => { cy.get('@input') .type('123') .type('{leftArrow}'.repeat(2)) .type('0') .should('have.value', '123') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('|123 => Type 9 => |123', () => { cy.get('@input') .type('123') .type('{moveToStart}') .type('9') .should('have.value', '123') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('Empty input => Paste 123456789 => 123|', () => { cy.get('@input') .focus() .paste('123456789') .should('have.value', '123') .should('have.prop', 'selectionStart', '123'.length) .should('have.prop', 'selectionEnd', '123'.length); }); }); }); describe(' & overwriteMode = replace', () => { beforeEach(() => { const maskitoOptions: MaskitoOptions = { mask: /^[A-F\d]*$/gi, overwriteMode: 'replace', postprocessors: [ ({value, selection}) => ({ selection, value: value.toUpperCase(), }), ], }; cy.mount(TestInput, { componentProperties: { maskitoOptions, maxLength: 6, }, }); cy.get('input[maxlength="6"]') .should('have.prop', 'maxlength', 6) .as('input'); }); it('accepts valid 526ed3', () => { cy.get('@input').type('526ed3').should('have.value', '526ED3'); }); describe('does not allow to type characters more than [maxlength]', () => { it('many letters', () => { cy.get('@input') .type('aaabbbcccdddeeefff') .should('have.value', 'AAABBB'); }); it('many digits', () => { cy.get('@input').type('1234567890').should('have.value', '123456'); }); }); it('overwriteMode `replace` works even if value`s length is equal to [maxlength]', () => { cy.get('@input') .type('123456') .type('{leftArrow}'.repeat(3)) .type('09') .should('have.value', '123096'); }); }); describe('with oversimplified Number mask', () => { beforeEach(() => { const maskitoOptions: MaskitoOptions = {mask: /^\d*$/}; cy.mount(TestInput, { componentProperties: { maskitoOptions, maxLength: 4, }, }); }); it('Press 4 (no more!) digits and click on cleaner => Empty textfield', () => { cy.get('input').type('1234').type('{selectAll}'); cy.document().then((doc) => doc.execCommand('delete')); cy.get('input').should('have.value', ''); }); it('Press >4 digits and click on cleaner => Empty textfield', () => { cy.get('input').type('123456').type('{selectAll}'); cy.document().then((doc) => doc.execCommand('delete')); cy.get('input').should('have.value', ''); }); }); describe('with Number mask', () => { beforeEach(() => { const inputYearMask: MaskitoOptions = maskitoNumberOptionsGenerator({ min: 0, max: 9999, thousandSeparator: '', }); @Component({ imports: [TestInput], template: ` `, changeDetection: ChangeDetectionStrategy.OnPush, }) class Sandbox { private readonly el = inject(ElementRef).nativeElement; protected readonly mask = inputYearMask; protected clear(): void { const input = this.el.querySelector('input'); input.select(); input.ownerDocument.execCommand('delete'); } } cy.mount(Sandbox); cy.get('input[maxlength="4"]') .should('have.prop', 'maxlength', 4) .as('input'); }); it('Press 4 (no more!) digits and click on cleaner => Empty textfield', () => { cy.get('@input').type('1234'); cy.get('#cleaner').click(); cy.get('@input').should('have.value', ''); }); it('Press >4 digits and click on cleaner => Empty textfield', () => { cy.get('@input').type('123456789'); cy.get('#cleaner').click(); cy.get('@input').should('have.value', ''); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/native-select-method/native-select-method.cy.ts ================================================ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {MaskitoDirective} from '@maskito/angular'; import type {MaskitoOptions} from '@maskito/core'; import {maskitoEventHandler} from '@maskito/kit'; @Component({ imports: [MaskitoDirective], template: `
`, changeDetection: ChangeDetectionStrategy.OnPush, }) export class TestComponent { protected readonly maskitoOptions: MaskitoOptions = { mask: /^\d+$/, plugins: [ maskitoEventHandler('focus', (element) => element.select(), {once: true}), ], }; } describe('Native method `.select()` works', () => { ['input', 'textarea'].forEach((selector) => { it(`for <${selector} />`, () => { cy.mount(TestComponent); cy.get(selector) .should('have.value', '123') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0) .should('not.be.focused') .focus() .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 3); }); }); it('for [contenteditable]', () => { cy.mount(TestComponent); cy.get('[contenteditable]') .should('have.text', '123') .should('not.be.focused') .focus() .type('0') // all selected value will be overwritten .should('have.text', '0') .focus() .type('2') // no selection (plugin works only for the first focus), just append value .should('have.text', '02'); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/number/alone-decimal-separator.cy.ts ================================================ import {maskitoNumberOptionsGenerator} from '@maskito/kit'; import {TestInput} from '../utils'; describe('Number | should drop decimal separator if all digits are erased', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions: maskitoNumberOptionsGenerator({ maximumFractionDigits: 2, minusSign: '-', }), }, }); cy.get('input').focus().should('have.value', ''); }); it('empty integer part & NOT empty decimal part => keeps decimal separator untouched', () => { cy.get('input') .type('0.12') .type('{backspace}'.repeat(2)) .should('have.value', '0.'); }); it('NOT empty integer part & empty decimal part => keeps decimal separator untouched', () => { cy.get('input') .type('0.12') .type('{moveToStart}') .type('{del}') .should('have.value', '.12'); }); describe('empty integer part & empty decimal part => drops decimal separator', () => { [ {minusSign: '', testTitle: 'Without minus'}, {minusSign: '-', testTitle: 'With minus'}, ].forEach(({testTitle, minusSign}) => { it(testTitle, () => { cy.get('input') .type(`${minusSign}0.12`) .type('{backspace}'.repeat(2)) .type(`{moveToStart}${'{rightArrow}'.repeat(minusSign.length)}`) .type('{del}') .should('have.value', minusSign) .should('have.prop', 'selectionStart', minusSign.length) .should('have.prop', 'selectionEnd', minusSign.length) // and then repeat everything in reversed order .type('0.12') .type(`{moveToStart}${'{rightArrow}'.repeat(minusSign.length)}`) .type('{del}') .type('{moveToEnd}') .type('{backspace}'.repeat(2)) .should('have.value', minusSign) .should('have.prop', 'selectionStart', minusSign.length) .should('have.prop', 'selectionEnd', minusSign.length); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/number/min-max-bigint.cy.ts ================================================ import {maskitoNumberOptionsGenerator} from '@maskito/kit'; import {TestInput} from '../utils'; describe('Number | min/max limits are bigint', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { initialValue: '', maskitoOptions: maskitoNumberOptionsGenerator({ min: BigInt(Number.MIN_SAFE_INTEGER) - 777n, max: BigInt(Number.MAX_SAFE_INTEGER) + 300n, thousandSeparator: '', maximumFractionDigits: Infinity, minusSign: '-', }), }, }); cy.get('input').focus().should('have.value', '').as('input'); }); describe('max', () => { it('Allows to enter > MAX_SAFE_INTEGER but less than max constraint', () => { cy.get('input') .type(String(BigInt(Number.MAX_SAFE_INTEGER) + 200n)) .should('have.value', String(BigInt(Number.MAX_SAFE_INTEGER) + 200n)); }); it('Allows to enter > MAX_SAFE_INTEGER but less than max constraint (even if decimal part contains many digits)', () => { const value = String( `${BigInt(Number.MAX_SAFE_INTEGER) + 100n}.12345678901234567890`, ); cy.get('input').type(value).should('have.value', value); }); it('forbids to enter value more than max', () => { cy.get('input') .type(String(BigInt(Number.MAX_SAFE_INTEGER) + 500n)) .should('have.value', String(BigInt(Number.MAX_SAFE_INTEGER) + 300n)); }); }); describe('min', () => { it('Allows to enter < MIN_SAFE_INTEGER but more than min constraint', () => { cy.get('input') .type(String(BigInt(Number.MIN_SAFE_INTEGER) - 500n)) .should('have.value', String(BigInt(Number.MIN_SAFE_INTEGER) - 500n)); }); it('Allows to enter < MIN_SAFE_INTEGER but more than min constraint (even if decimal part contains many digits)', () => { const value = String( `${BigInt(Number.MIN_SAFE_INTEGER) - 500n}.12345678901234567890`, ); cy.get('input').type(value).should('have.value', value); }); it('forbids to enter value less than min', () => { cy.get('input') .type(String(BigInt(Number.MIN_SAFE_INTEGER) - 999n)) .should('have.value', String(BigInt(Number.MIN_SAFE_INTEGER) - 777n)); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/number/mirrored-prefix-postfix.cy.ts ================================================ import {maskitoNumberOptionsGenerator} from '@maskito/kit'; import {BROWSER_SUPPORTS_REAL_EVENTS} from 'projects/demo-integrations/src/support/constants'; import {repeatKey} from '../../utils'; import {TestInput} from '../utils'; describe('Number | [prefix]="$ " | [postfix]=" per day" (without caret guard)', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { initialValue: '$ 100 per day', maskitoOptions: maskitoNumberOptionsGenerator({ prefix: '$ ', postfix: ' per day', }), }, }); cy.get('input') .focus() .type('{selectAll}{del}') .should('have.value', '$ per day') .should('have.prop', 'selectionStart', '$ '.length) .should('have.prop', 'selectionEnd', '$ '.length) .as('input'); }); it('$ per day| => Type Backspace => $ per da|y', () => { cy.get('@input') .type('{moveToEnd}') .type('{backspace}') .should('have.value', '$ per day') .should('have.prop', 'selectionStart', '$ per da'.length) .should('have.prop', 'selectionEnd', '$ per da'.length); }); it('$ per da|y => Type Backspace => $ per d|ay', () => { cy.get('@input') .type('{moveToEnd}{leftArrow}') .type('{backspace}') .should('have.value', '$ per day') .should('have.prop', 'selectionStart', '$ per d'.length) .should('have.prop', 'selectionEnd', '$ per d'.length); }); it( '$ p|er |day => Type Backspace => $ p|er da|y', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('{moveToEnd}') .type('{leftArrow}'.repeat('day'.length)) .realPress(['Shift', ...repeatKey('ArrowLeft', '1.1'.length)]); cy.get('@input') .type('{backspace}') .should('have.value', '$ per day') .should('have.prop', 'selectionStart', '$ p'.length) .should('have.prop', 'selectionEnd', '$ p'.length); }, ); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/number/mirrored-value-postfix.cy.ts ================================================ import {maskitoNumberOptionsGenerator} from '@maskito/kit'; import {TestInput} from '../utils'; describe('Number | [postfix]=" EUR" (no initial value & no caret guard)', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { initialValue: '', maskitoOptions: maskitoNumberOptionsGenerator({ postfix: ' EUR', maximumFractionDigits: 2, }), }, }); cy.get('input').focus().should('have.value', '').as('input'); }); it('Empty input => Paste "11.22 " => 11.22 |EUR', () => { cy.get('input') .paste('11.22 ') .should('have.value', '11.22 EUR') .should('have.prop', 'selectionStart', '11.22 '.length) .should('have.prop', 'selectionEnd', '11.22 '.length); }); it('Empty input => Paste "11.22 " (with two trailing spaces) => 11.22 |EUR', () => { cy.get('input') .paste('11.22 ') .should('have.value', '11.22 EUR') .should('have.prop', 'selectionStart', '11.22 '.length) .should('have.prop', 'selectionEnd', '11.22 '.length); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/number/multi-character-prefix.cy.ts ================================================ import {maskitoNumberOptionsGenerator} from '@maskito/kit'; import {TestInput} from '../utils'; describe('Number | multi-character prefix "EUR " (no initial value & no caret guard)', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions: maskitoNumberOptionsGenerator({prefix: 'EUR '}), }, }); cy.get('input').focus().should('have.value', '').as('input'); }); ['E', 'U', 'R'].forEach((char) => { it(`Empty input => Type "${char} => Textfield's value is "EUR "`, () => { cy.get('@input') .type(char) .should('have.value', 'EUR ') .should('have.prop', 'selectionStart', 'EUR '.length) .should('have.prop', 'selectionEnd', 'EUR '.length); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/number/overwrite-selection-range.cy.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import {maskitoNumberOptionsGenerator} from '@maskito/kit'; import {TestInput} from '../utils'; describe('Number | overwrite selection range', () => { const numberOptions = maskitoNumberOptionsGenerator({maximumFractionDigits: 3}); it('overwrite of selection range inside preprocessor works ', () => { const maskitoOptions: MaskitoOptions = { ...numberOptions, preprocessors: [ ({elementState, data}, inputType) => { const {value, selection} = elementState; const [start, end] = selection; return { data, elementState: { value, selection: inputType === 'insert' && !start && !end && value.startsWith('0') ? [0, 1] : selection, }, }; }, ...numberOptions.preprocessors, ], }; cy.mount(TestInput, { componentProperties: {maskitoOptions, initialValue: '0.234'}, }); cy.get('input') .focus() .type('{moveToStart}') .type('1') .should('have.value', '1.234') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('overwrite of selection range inside postprocessor works ', () => { const maskitoOptions: MaskitoOptions = { mask: /^\d*(?:\.\d*)?$/, overwriteMode: 'replace', postprocessors: [ (elementState) => elementState.value === '.' ? {value: '0.00', selection: [2, 2]} : elementState, ], }; cy.mount(TestInput, {componentProperties: {maskitoOptions}}); cy.get('input') .type('.') .should('have.value', '0.00') .should('have.prop', 'selectionStart', 2) .should('have.prop', 'selectionEnd', 2); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/number/postfix-multi-character.cy.ts ================================================ import {maskitoNumberOptionsGenerator} from '@maskito/kit'; import {TestInput} from '../utils'; describe('Number | postfix consists of many characters', () => { describe('postfix = ` lbs. per day`', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions: maskitoNumberOptionsGenerator({ postfix: ' lbs. per day', thousandSeparator: ' ', decimalSeparator: '.', maximumFractionDigits: 2, }), }, }); cy.get('input').focus(); }); it('Paste 100', () => { cy.get('input').paste('100 ').should('have.value', '100 lbs. per day'); }); it('Paste 100.', () => { cy.get('input').paste('100.').should('have.value', '100. lbs. per day'); }); it('Paste 100.42', () => { cy.get('input').paste('100.42').should('have.value', '100.42 lbs. per day'); }); it('Paste 100 lbs', () => { cy.get('input').paste('100 lbs').should('have.value', '100 lbs. per day'); }); it('Paste 100 lbs.', () => { cy.get('input').paste('100 lbs.').should('have.value', '100 lbs. per day'); }); it('Paste 100. lbs.', () => { cy.get('input').paste('100. lbs.').should('have.value', '100. lbs. per day'); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/number/postfix-with-point.cy.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import { maskitoAddOnFocusPlugin, maskitoCaretGuard, maskitoNumberOptionsGenerator, maskitoRemoveOnBlurPlugin, } from '@maskito/kit'; import {TestInput} from '../utils'; describe('Number | postfix with point', () => { describe('` lbs.` postfix', () => { describe('Basic', () => { const maskitoOptions = maskitoNumberOptionsGenerator({ postfix: ' lbs.', thousandSeparator: ' ', maximumFractionDigits: 2, }); it('Empty => Type 5 => 5 lbs.', () => { cy.mount(TestInput, {componentProperties: {maskitoOptions}}); cy.get('input') .type('5') .should('have.value', '5 lbs.') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('Empty => Type 12 => 12 lbs.', () => { cy.mount(TestInput, {componentProperties: {maskitoOptions}}); cy.get('input') .type('12') .should('have.value', '12 lbs.') .should('have.prop', 'selectionStart', 2) .should('have.prop', 'selectionEnd', 2); }); it('Empty => Type 0.42 => 0.42 lbs.', () => { cy.mount(TestInput, {componentProperties: {maskitoOptions}}); cy.get('input') .type('0') .should('have.value', '0 lbs.') .type('.') .should('have.value', '0. lbs.') .should('have.prop', 'selectionStart', '0.'.length) .should('have.prop', 'selectionEnd', '0.'.length) .type('42') .should('have.value', '0.42 lbs.') .should('have.prop', 'selectionStart', '0.42'.length) .should('have.prop', 'selectionEnd', '0.42'.length); }); }); describe('Complex: maskitoCaretGuard + maskitoAddOnFocusPlugin + maskitoRemoveOnBlurPlugin', () => { const postfix = ' lbs.'; const numberOptions = maskitoNumberOptionsGenerator({postfix}); const maskitoOptions: MaskitoOptions = { ...numberOptions, plugins: [ ...numberOptions.plugins, maskitoAddOnFocusPlugin(postfix), maskitoRemoveOnBlurPlugin(postfix), maskitoCaretGuard((value) => [0, value.length - postfix.length]), ], }; it('Empty textfield => Focus => | lbs.', () => { cy.mount(TestInput, {componentProperties: {maskitoOptions}}); cy.get('input') .should('have.value', '') .focus() .should('have.value', postfix) .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('Textfield contains only postfix => Blur => | Empty textfield', () => { cy.mount(TestInput, {componentProperties: {maskitoOptions}}); cy.get('input') .focus() .should('have.value', postfix) .blur() .should('have.value', ''); }); it('| lbs. => Type 5 => 5 lbs.', () => { cy.mount(TestInput, {componentProperties: {maskitoOptions}}); cy.get('input') .type('5') .should('have.value', '5 lbs.') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('123| lbs. => Backspace => 12 lbs.', () => { cy.mount(TestInput, {componentProperties: {maskitoOptions}}); cy.get('input') .type('123') .should('have.value', '123 lbs.') .should('have.prop', 'selectionStart', 3) .should('have.prop', 'selectionEnd', 3) .type('{backspace}') .should('have.value', '12 lbs.') .should('have.prop', 'selectionStart', 2) .should('have.prop', 'selectionEnd', 2); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/number/runtime-postfix-changes/runtime-postfix-changes.cy.ts ================================================ import {Sandbox} from './sandbox.component'; describe('Number | runtime changes of postfix', () => { describe('year & years', () => { beforeEach(() => { cy.mount(Sandbox, {componentProperties: {value: '1 year'}}); cy.get('input').focus().should('have.value', '1 year').as('input'); }); it('1| year => Type 0 => 10| years', () => { cy.get('@input') .type('{moveToStart}{rightArrow}') .type('0') .should('have.value', '10 years') .should('have.prop', 'selectionStart', '10'.length) .should('have.prop', 'selectionEnd', '10'.length); }); it('1| year => Backspace => Empty', () => { cy.get('@input') .type('{moveToStart}{rightArrow}') .type('{backspace}') .should('have.value', '') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('10| years => Backspace => 1| year', () => { cy.get('@input') .type('{moveToStart}{rightArrow}') .type('0') .should('have.value', '10 years') .type('{backspace}') .should('have.value', '1 year') .should('have.prop', 'selectionStart', '1'.length) .should('have.prop', 'selectionEnd', '1'.length); }); it('select all + delete', () => { cy.get('@input') .should('have.value', '1 year') .type('{selectAll}{del}') .should('have.value', '') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); }); describe('mouse & mice (new postfix ends with the same characters as previous postfix; but has different beginning part)', () => { const pluralize = { '=NaN': '', '=1': ' mouse', other: ' mice', }; it('10| mice => Backspace => 1 mouse', () => { cy.mount(Sandbox, {componentProperties: {pluralize, value: '10 mice'}}); cy.get('input') .type('{moveToStart}') .type('{rightArrow}'.repeat(2)) .type('{backspace}') .should('have.value', '1 mouse') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('1| mouse => Type 0 => 10| mice', () => { cy.mount(Sandbox, {componentProperties: {pluralize, value: '1 mouse'}}); cy.get('input') .type('{moveToStart}') .type('{rightArrow}') .type('0') .should('have.value', '10 mice') .should('have.prop', 'selectionStart', 2) .should('have.prop', 'selectionEnd', 2); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/number/runtime-postfix-changes/sandbox.component.ts ================================================ import {I18nPluralPipe} from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, input, model, Pipe, type PipeTransform, } from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MaskitoDirective} from '@maskito/angular'; import {maskitoInitialCalibrationPlugin, type MaskitoOptions} from '@maskito/core'; import {maskitoNumberOptionsGenerator, maskitoParseNumber} from '@maskito/kit'; @Pipe({name: 'calculateMask'}) export class TestPipe4 implements PipeTransform { public transform(postfix: string): MaskitoOptions { const options = maskitoNumberOptionsGenerator({ postfix, thousandSeparator: ' ', }); return { ...options, plugins: [...options.plugins, maskitoInitialCalibrationPlugin()], }; } } @Component({ selector: 'test-doc-example-4', imports: [FormsModule, I18nPluralPipe, MaskitoDirective, TestPipe4], template: ` `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class Sandbox { protected readonly value = model(''); protected readonly parsedValue = computed(() => maskitoParseNumber(this.value())); protected readonly pluralize = input({ '=NaN': '', one: ' year', few: ' years', many: ' years', other: ' years', }); } ================================================ FILE: projects/demo-integrations/src/tests/component-testing/number/with-initial-value.cy.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import {maskitoCaretGuard, maskitoNumberOptionsGenerator} from '@maskito/kit'; import {BROWSER_SUPPORTS_REAL_EVENTS} from '../../../support/constants'; import {repeatKey} from '../../utils'; import {TestInput} from '../utils'; describe('Number | With initial value', () => { let maskitoOptions!: MaskitoOptions; describe('with prefix & postfix', () => { beforeEach(() => { const prefix = '$'; const postfix = ' kg'; const numberOptions = maskitoNumberOptionsGenerator({ prefix, postfix, thousandSeparator: ' ', }); maskitoOptions = { ...numberOptions, plugins: [ ...numberOptions.plugins, maskitoCaretGuard((value) => [ prefix.length, value.length - postfix.length, ]), ], }; }); it('$6 432 kg => Select all + Backspace => $| kg', () => { cy.mount(TestInput, { componentProperties: {maskitoOptions, initialValue: '$6 432 kg'}, }); cy.get('input') .type('{selectAll}{backspace}') .should('have.value', '$ kg') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('$6 4|32 kg => Delete => $64|2 kg', () => { cy.mount(TestInput, { componentProperties: {maskitoOptions, initialValue: '$6 432 kg'}, }); cy.get('input') .focus() .type('{moveToEnd}') .should('have.prop', 'selectionStart', '$6 432'.length) .should('have.prop', 'selectionEnd', '$6 432'.length) .type('{leftArrow}'.repeat(2)) .should('have.prop', 'selectionStart', '$6 4'.length) .should('have.prop', 'selectionEnd', '$6 4'.length) .type('{del}') .should('have.value', '$642 kg') .should('have.prop', 'selectionStart', '$64'.length) .should('have.prop', 'selectionEnd', '$64'.length); }); }); it('123 45|6 789 => Type 0 (the 1st time input event) => 1 234 50|6 789', () => { cy.mount(TestInput, { componentProperties: { maskitoOptions: maskitoNumberOptionsGenerator({thousandSeparator: ' '}), initialValue: '123 456 789', }, }); cy.get('input') .focus() .type('{moveToStart}') .type('{rightArrow}'.repeat('123 45'.length)) .type('0') .should('have.value', '1 234 506 789') .should('have.prop', 'selectionStart', '1 234 50'.length) .should('have.prop', 'selectionEnd', '1 234 50'.length); }); describe('select all initial value', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions: maskitoNumberOptionsGenerator({ thousandSeparator: '_', }), initialValue: '1_234', }, }); }); it('and press Delete', () => { cy.get('input') .focus() .should('have.value', '1_234') .type('{selectAll}{del}') .should('have.value', '') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('and press Backspace', () => { cy.get('input') .focus() .should('have.value', '1_234') .type('{selectAll}{backspace}') .should('have.value', '') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); }); describe('select some existing characters and then type new digit', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions: maskitoNumberOptionsGenerator({ thousandSeparator: '_', }), initialValue: '123_456', }, }); }); it( 'Initial 12|3_456| => Type 9 (the 1st (input) event) => 129|', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('input') .type('{moveToStart}') .type('{rightArrow}'.repeat('12'.length)) .realPress(['Shift', ...repeatKey('ArrowRight', '3_456'.length)]); cy.get('input') .type('9') .should('have.value', '129') .should('have.prop', 'selectionStart', '129'.length) .should('have.prop', 'selectionEnd', '129'.length); }, ); it( 'Enter 12|3_456| => Type 9 (NOT the 1st (input) event) => 129|', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('input') .clear() .type('123_456') .realPress(['Shift', ...repeatKey('ArrowLeft', '3_456'.length)]); cy.get('input') .type('9') .should('have.value', '129') .should('have.prop', 'selectionStart', '129'.length) .should('have.prop', 'selectionEnd', '129'.length); }, ); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/overwrite-mode/overwrite-mode-replace.cy.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import {BROWSER_SUPPORTS_REAL_EVENTS} from '../../../support/constants'; import {repeatKey} from '../../utils'; import {TestInput} from '../utils'; describe('overwriteMode = replace', () => { const digitsOnlyMask: MaskitoOptions = { mask: /^\d+$/g, overwriteMode: 'replace', }; describe('selection contains several characters', () => { beforeEach(() => { cy.mount(TestInput, {componentProperties: {maskitoOptions: digitsOnlyMask}}); }); it('12|34| => Press 0 => 120', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('input') .type('1234') .should('have.value', '1234') .realPress(['Shift', ...repeatKey('ArrowLeft', '34'.length)]); cy.get('input').type('0').should('have.value', '120'); }); it('1|23|4 => Press 0 => 104', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('input') .type('1234') .should('have.value', '1234') .realPress([ 'ArrowLeft', 'Shift', ...repeatKey('ArrowLeft', '23'.length), ]); cy.get('input').type('0').should('have.value', '104'); }); it('1234 => select all => Press 0 => 0', () => { cy.get('input') .type('1234') .should('have.value', '1234') .type('{selectall}') .type('0') .should('have.value', '0'); }); it('1|23|4 => Backspace => 14', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('input') .type('1234') .should('have.value', '1234') .realPress([ 'ArrowLeft', 'Shift', ...repeatKey('ArrowLeft', '23'.length), ]); cy.get('input').type('{backspace}').should('have.value', '14'); }); it('1|23|4 => Delete => 14', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('input') .type('1234') .should('have.value', '1234') .realPress([ 'ArrowLeft', 'Shift', ...repeatKey('ArrowLeft', '23'.length), ]); cy.get('input').type('{del}').should('have.value', '14'); }); }); describe('selectionStart === selectionEnd', () => { beforeEach(() => { cy.mount(TestInput, {componentProperties: {maskitoOptions: digitsOnlyMask}}); }); it('|123 => Press 0 => 0|23', () => { cy.get('input') .type('123') .should('have.value', '123') .type('{moveToStart}') .type('0') .should('have.value', '023') .should('have.a.prop', 'selectionStart', 1) .should('have.a.prop', 'selectionEnd', 1); }); it('1|23 => Press 0 => 10|3', () => { cy.get('input') .type('123') .should('have.value', '123') .type('{moveToStart}{rightArrow}') .type('0') .should('have.value', '103') .should('have.a.prop', 'selectionStart', 2) .should('have.a.prop', 'selectionEnd', 2); }); it('12|3 => Press 0 => 120|', () => { cy.get('input') .type('123') .should('have.value', '123') .type('{moveToEnd}{leftArrow}') .type('0') .should('have.value', '120') .should('have.a.prop', 'selectionStart', 3) .should('have.a.prop', 'selectionEnd', 3); }); it('123| => Press 4 => 1234|', () => { cy.get('input') .type('123') .should('have.value', '123') .type('4') .should('have.value', '1234') .should('have.a.prop', 'selectionStart', 4) .should('have.a.prop', 'selectionEnd', 4); }); it('12|3 => Press Backspace => 1|3', () => { cy.get('input') .type('123') .should('have.value', '123') .type('{moveToEnd}{leftArrow}') .type('{backspace}') .should('have.value', '13') .should('have.a.prop', 'selectionStart', 1) .should('have.a.prop', 'selectionEnd', 1); }); it('1|23 => Press Delete => 1|3', () => { cy.get('input') .type('123') .should('have.value', '123') .type('{moveToStart}{rightArrow}') .type('{del}') .should('have.value', '13') .should('have.a.prop', 'selectionStart', 1) .should('have.a.prop', 'selectionEnd', 1); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/paste/cy-paste-utility.cy.ts ================================================ import {MaskitoDirective} from '@maskito/angular'; import {createOutputSpy} from 'cypress/angular'; import {TestInput} from '../utils'; describe('Ensure cy.paste() emulates required browser features', () => { const maskitoOptions = {mask: /^\d+$/g}; describe('Emits `beforeinput` event', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions, beforeinput: createOutputSpy('beforeinputEvent'), }, }); cy.get('input').focus(); }); it('only once', () => { cy.get('input').paste('123'); cy.get('@beforeinputEvent').should('have.been.calledOnce'); }); it('with `inputType: insertFromPaste`', () => { cy.get('input').paste('123'); cy.get('@beforeinputEvent').should('have.been.calledWithMatch', { inputType: 'insertFromPaste', }); }); it('with `data` property', () => { cy.get('input').paste('123'); cy.get('@beforeinputEvent').should('have.been.calledWithMatch', { data: '123', }); }); }); describe('Emits `input` event', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions, beforeinput: createOutputSpy('beforeinputEvent'), input: createOutputSpy('inputEvent'), }, }); cy.get('input').focus(); }); it('only once', () => { cy.get('input').paste('123'); cy.get('@inputEvent').should('have.been.calledOnce'); }); it('after `beforeinput` event', () => { cy.get('input').paste('123'); cy.get('@beforeinputEvent').then((beforeSpy) => cy.get('@inputEvent').should('have.been.calledAfter', beforeSpy), ); }); it('only if the previous `beforeinput` event was not prevented', () => { cy.get('input').paste('abc'); cy.get('@beforeinputEvent').should('have.been.calledOnce'); cy.get('@inputEvent').should('not.have.been.called'); }); }); describe('HTMLInputElement.value', () => { beforeEach(() => { cy.mount( ` `, { imports: [MaskitoDirective], componentProperties: { maskitoOptions, beforeinput: createOutputSpy('beforeinputEvent'), input: createOutputSpy('inputEvent'), }, }, ); cy.get('input').focus(); }); it('is not yet updated in `beforeinput` event`', () => { cy.get('input').paste('123'); cy.get('@beforeinputEvent').should('have.been.calledOnceWith', ''); }); it('is already updated in `input` event', () => { cy.get('input').paste('123'); cy.get('@inputEvent').should('have.been.calledOnceWith', '123'); }); }); it('respects the `maxlength` attribute', () => { cy.mount(TestInput, {componentProperties: {maskitoOptions, maxLength: 3}}); cy.get('input').focus().paste('12345').should('have.value', '123'); }); it('is capable to paste in the middle of already existing value', () => { cy.mount(TestInput, {componentProperties: {maskitoOptions}}); cy.get('input') .type('15') .type('{leftArrow}') .paste('234') .should('have.value', '12345') .should('have.prop', 'selectionStart', '1234'.length) .should('have.prop', 'selectionEnd', '1234'.length); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/phone/phone-national-format.cy.ts ================================================ import {maskitoPhoneOptionsGenerator} from '@maskito/phone'; import metadata from 'libphonenumber-js/min/metadata'; import {TestInput} from '../utils'; describe('Phone | National format', () => { describe('United States', () => { describe('Typing digits', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions: maskitoPhoneOptionsGenerator({ countryIsoCode: 'US', metadata, format: 'NATIONAL', }), initialValue: '', }, }); }); it('Type 2123433355 => (212) 343-3355', () => { cy.get('input') .focus() .type('2123433355') .should('have.value', '(212) 343-3355'); }); it('Type 212 => (212)', () => { cy.get('input').focus().type('212').should('have.value', '(212)'); }); it('Type 2123 => (212) 3', () => { cy.get('input').focus().type('2123').should('have.value', '(212) 3'); }); it('Type 212343 => (212) 343', () => { cy.get('input').focus().type('212343').should('have.value', '(212) 343'); }); }); describe('Backspace behavior', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions: maskitoPhoneOptionsGenerator({ countryIsoCode: 'US', metadata, format: 'NATIONAL', }), initialValue: '(212) 343-3355', }, }); }); it('(212) 343-3355| => Backspace => (212) 343-335|', () => { cy.get('input') .should('have.value', '(212) 343-3355') .focus() .type('{moveToEnd}') .type('{backspace}') .should('have.value', '(212) 343-335') .should('have.prop', 'selectionStart', '(212) 343-335'.length) .should('have.prop', 'selectionEnd', '(212) 343-335'.length); }); it('(212) 343|-3355 => Backspace => (212) 34|3-355', () => { cy.get('input') .should('have.value', '(212) 343-3355') .focus() .type('{moveToEnd}') .type('{leftArrow}'.repeat('-3355'.length)) .type('{backspace}') .should('have.value', '(212) 343-355') .should('have.prop', 'selectionStart', '(212) 34'.length) .should('have.prop', 'selectionEnd', '(212) 34'.length); }); it('(212) 3|43-3355 => Backspace => (212) |433-355', () => { cy.get('input') .should('have.value', '(212) 343-3355') .focus() .type('{moveToEnd}') .type('{leftArrow}'.repeat('43-3355'.length)) .type('{backspace}') .should('have.value', '(212) 433-355') .should('have.prop', 'selectionStart', '(212) '.length) .should('have.prop', 'selectionEnd', '(212) '.length); }); }); }); describe('Russia', () => { describe('Typing digits', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions: maskitoPhoneOptionsGenerator({ countryIsoCode: 'RU', metadata, format: 'NATIONAL', }), initialValue: '', }, }); }); it('Type 9202800155 => 920 280-01-55', () => { cy.get('input') .focus() .type('9202800155') .should('have.value', '920 280-01-55'); }); it('Type 920 => 920', () => { cy.get('input').focus().type('920').should('have.value', '920'); }); it('Type 9202 => 920 2', () => { cy.get('input').focus().type('9202').should('have.value', '920 2'); }); it('Type 920280 => 920 280', () => { cy.get('input').focus().type('920280').should('have.value', '920 280'); }); }); describe('Backspace behavior', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions: maskitoPhoneOptionsGenerator({ countryIsoCode: 'RU', metadata, format: 'NATIONAL', }), initialValue: '920 280-01-55', }, }); }); it('920 280-01-55| => Backspace => 920 280-01-5|', () => { cy.get('input') .should('have.value', '920 280-01-55') .focus() .type('{moveToEnd}') .type('{backspace}') .should('have.value', '920 280-01-5') .should('have.prop', 'selectionStart', '920 280-01-5'.length) .should('have.prop', 'selectionEnd', '920 280-01-5'.length); }); it('920 280-01|-55 => Backspace => 920 280-0|5-5', () => { cy.get('input') .should('have.value', '920 280-01-55') .focus() .type('{moveToEnd}') .type('{leftArrow}'.repeat('-55'.length)) .type('{backspace}') .should('have.value', '920 280-05-5') .should('have.prop', 'selectionStart', '920 280-0'.length) .should('have.prop', 'selectionEnd', '920 280-0'.length); }); it('920 2|80-01-55 => Backspace => 920 |800-15-5', () => { cy.get('input') .should('have.value', '920 280-01-55') .focus() .type('{moveToEnd}') .type('{leftArrow}'.repeat('80-01-55'.length)) .type('{backspace}') .should('have.value', '920 800-15-5') .should('have.prop', 'selectionStart', '920 '.length) .should('have.prop', 'selectionEnd', '920 '.length); }); }); }); describe('Spain', () => { describe('Typing digits', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions: maskitoPhoneOptionsGenerator({ countryIsoCode: 'ES', metadata, format: 'NATIONAL', }), initialValue: '', }, }); }); it('Type 612345678 => 612 34-56-78', () => { cy.get('input') .focus() .type('612345678') .should('have.value', '612 34-56-78'); }); it('Type 612 => 612', () => { cy.get('input').focus().type('612').should('have.value', '612'); }); it('Type 6123 => 612 3', () => { cy.get('input').focus().type('6123').should('have.value', '612 3'); }); it('Type 612345 => 612 34-5', () => { cy.get('input').focus().type('612345').should('have.value', '612 34-5'); }); }); describe('Backspace behavior', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions: maskitoPhoneOptionsGenerator({ countryIsoCode: 'ES', metadata, format: 'NATIONAL', }), initialValue: '612 34-56-78', }, }); }); it('612 34-56-78| => Backspace => 612 34-56-7|', () => { cy.get('input') .should('have.value', '612 34-56-78') .focus() .type('{moveToEnd}') .type('{backspace}') .should('have.value', '612 34-56-7') .should('have.prop', 'selectionStart', '612 34-56-7'.length) .should('have.prop', 'selectionEnd', '612 34-56-7'.length); }); it('612 34-56|-78 => Backspace => 612 34-5|7-8', () => { cy.get('input') .should('have.value', '612 34-56-78') .focus() .type('{moveToEnd}') .type('{leftArrow}'.repeat('-78'.length)) .type('{backspace}') .should('have.value', '612 34-57-8') .should('have.prop', 'selectionStart', '612 34-5'.length) .should('have.prop', 'selectionEnd', '612 34-5'.length); }); it('612 3|4-56-78 => Backspace => 612 |45-67-8', () => { cy.get('input') .should('have.value', '612 34-56-78') .focus() .type('{moveToEnd}') .type('{leftArrow}'.repeat('4-56-78'.length)) .type('{backspace}') .should('have.value', '612 45-67-8') .should('have.prop', 'selectionStart', '612 '.length) .should('have.prop', 'selectionEnd', '612 '.length); }); }); }); describe('France', () => { describe('Typing digits', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions: maskitoPhoneOptionsGenerator({ countryIsoCode: 'FR', metadata, format: 'NATIONAL', }), initialValue: '', }, }); }); it('Type 0612345678 => 06 12-34-56-78', () => { cy.get('input') .focus() .type('0612345678') .should('have.value', '06 12-34-56-78'); }); it('Type 06 => 06', () => { cy.get('input').focus().type('06').should('have.value', '06'); }); it('Type 0612 => 06 12', () => { cy.get('input').focus().type('0612').should('have.value', '06 12'); }); it('Type 061234 => 06 12-34', () => { cy.get('input').focus().type('061234').should('have.value', '06 12-34'); }); }); describe('Backspace behavior', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions: maskitoPhoneOptionsGenerator({ countryIsoCode: 'FR', metadata, format: 'NATIONAL', }), initialValue: '06 12-34-56-78', }, }); }); it('06 12-34-56-78| => Backspace => 06 12-34-56-7|', () => { cy.get('input') .should('have.value', '06 12-34-56-78') .focus() .type('{moveToEnd}') .type('{backspace}') .should('have.value', '06 12-34-56-7') .should('have.prop', 'selectionStart', '06 12-34-56-7'.length) .should('have.prop', 'selectionEnd', '06 12-34-56-7'.length); }); it('06 12-34-56|-78 => Backspace => 06 12-34-5|7-8', () => { cy.get('input') .should('have.value', '06 12-34-56-78') .focus() .type('{moveToEnd}') .type('{leftArrow}'.repeat('-78'.length)) .type('{backspace}') .should('have.value', '06 12-34-57-8') .should('have.prop', 'selectionStart', '06 12-34-5'.length) .should('have.prop', 'selectionEnd', '06 12-34-5'.length); }); it('06 12-3|4-56-78 => Backspace => 06 12-|45-67-8', () => { cy.get('input') .should('have.value', '06 12-34-56-78') .focus() .type('{moveToEnd}') .type('{leftArrow}'.repeat('4-56-78'.length)) .type('{backspace}') .should('have.value', '06 12-45-67-8') .should('have.prop', 'selectionStart', '06 12-'.length) .should('have.prop', 'selectionEnd', '06 12-'.length); }); }); }); describe('Custom separator', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions: maskitoPhoneOptionsGenerator({ countryIsoCode: 'US', metadata, format: 'NATIONAL', separator: ' ', }), initialValue: '', }, }); }); it('Type 2123433355 with space separator => (212) 343 3355', () => { cy.get('input') .focus() .type('2123433355') .should('have.value', '(212) 343 3355'); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/phone/phone-with-initial-value.cy.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import {maskitoPhoneOptionsGenerator} from '@maskito/phone'; import metadata from 'libphonenumber-js/min/metadata'; import {TestInput} from '../utils'; describe('Phone | With initial value', () => { describe('Strict mode (Kazakhstan)', () => { // Create fresh options for each test to ensure clean closure state function createMaskitoOptions(): MaskitoOptions { return maskitoPhoneOptionsGenerator({ countryIsoCode: 'KZ', metadata, strict: true, }); } describe('Backspace on initial render', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions: createMaskitoOptions(), initialValue: '+7 771 931-1111', }, }); }); it('+7 771 931-1111| => Backspace => +7 771 931-111|', () => { cy.get('input') .should('be.visible') .should('have.value', '+7 771 931-1111') .focus() .type('{moveToEnd}') .type('{backspace}') .should('have.value', '+7 771 931-111') .should('have.prop', 'selectionStart', '+7 771 931-111'.length) .should('have.prop', 'selectionEnd', '+7 771 931-111'.length); }); it('+7 771 9|31-1111 => Backspace => +7 771 |311-111', () => { cy.get('input') .should('be.visible') .should('have.value', '+7 771 931-1111') .focus() .type('{moveToEnd}') .type('{leftArrow}'.repeat('31-1111'.length)) .should('have.prop', 'selectionStart', '+7 771 9'.length) .should('have.prop', 'selectionEnd', '+7 771 9'.length) .type('{backspace}') .should('have.value', '+7 771 311-111') .should('have.prop', 'selectionStart', '+7 771 '.length) .should('have.prop', 'selectionEnd', '+7 771 '.length); }); it('+7 771 93|1-1111 => Backspace => +7 771 9|11-111', () => { cy.get('input') .should('be.visible') .should('have.value', '+7 771 931-1111') .focus() .type('{moveToEnd}') .type('{leftArrow}'.repeat('1-1111'.length)) .should('have.prop', 'selectionStart', '+7 771 93'.length) .should('have.prop', 'selectionEnd', '+7 771 93'.length) .type('{backspace}') .should('have.value', '+7 771 911-111') .should('have.prop', 'selectionStart', '+7 771 9'.length) .should('have.prop', 'selectionEnd', '+7 771 9'.length); }); it('+7 771 931|-1111 => Backspace => +7 771 93|1-111', () => { cy.get('input') .should('be.visible') .should('have.value', '+7 771 931-1111') .focus() .type('{moveToEnd}') .type('{leftArrow}'.repeat('-1111'.length)) .should('have.prop', 'selectionStart', '+7 771 931'.length) .should('have.prop', 'selectionEnd', '+7 771 931'.length) .type('{backspace}') .should('have.value', '+7 771 931-111') .should('have.prop', 'selectionStart', '+7 771 93'.length) .should('have.prop', 'selectionEnd', '+7 771 93'.length); }); it('+7 77|1 931-1111 => Backspace => +7 7|19 311-111', () => { cy.get('input') .should('be.visible') .should('have.value', '+7 771 931-1111') .focus() .type('{moveToEnd}') .type('{leftArrow}'.repeat('1 931-1111'.length)) .should('have.prop', 'selectionStart', '+7 77'.length) .should('have.prop', 'selectionEnd', '+7 77'.length) .type('{backspace}') .should('have.value', '+7 719 311-111') .should('have.prop', 'selectionStart', '+7 7'.length) .should('have.prop', 'selectionEnd', '+7 7'.length); }); }); describe('Backspace after typing (confirms mask works when value is typed)', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions: createMaskitoOptions(), initialValue: '+7 ', }, }); }); it('Type value, then backspace in middle works correctly', () => { cy.get('input') .should('be.visible') .focus() .type('7719311111') .should('have.value', '+7 771 931-1111') // Now test backspace in the middle .type('{leftArrow}'.repeat('31-1111'.length)) .should('have.prop', 'selectionStart', '+7 771 9'.length) .should('have.prop', 'selectionEnd', '+7 771 9'.length) .type('{backspace}') .should('have.value', '+7 771 311-111') .should('have.prop', 'selectionStart', '+7 771 '.length) .should('have.prop', 'selectionEnd', '+7 771 '.length); }); }); }); describe('Strict mode (United States)', () => { describe('Backspace on initial render', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions: maskitoPhoneOptionsGenerator({ countryIsoCode: 'US', metadata, strict: true, }), initialValue: '+1 212 343-3355', }, }); }); it('+1 212 343-3355| => Backspace => +1 212 343-335|', () => { cy.get('input') .should('be.visible') .should('have.value', '+1 212 343-3355') .focus() .type('{moveToEnd}') .type('{backspace}') .should('have.value', '+1 212 343-335') .should('have.prop', 'selectionStart', '+1 212 343-335'.length) .should('have.prop', 'selectionEnd', '+1 212 343-335'.length); }); it('+1 212 3|43-3355 => Backspace => +1 212 |433-355', () => { cy.get('input') .should('be.visible') .should('have.value', '+1 212 343-3355') .focus() .type('{moveToEnd}') .type('{leftArrow}'.repeat('43-3355'.length)) .should('have.prop', 'selectionStart', '+1 212 3'.length) .should('have.prop', 'selectionEnd', '+1 212 3'.length) .type('{backspace}') .should('have.value', '+1 212 433-355') .should('have.prop', 'selectionStart', '+1 212 '.length) .should('have.prop', 'selectionEnd', '+1 212 '.length); }); it('+1 212 343|-3355 => Backspace => +1 212 34|3-355', () => { cy.get('input') .should('be.visible') .should('have.value', '+1 212 343-3355') .focus() .type('{moveToEnd}') .type('{leftArrow}'.repeat('-3355'.length)) .should('have.prop', 'selectionStart', '+1 212 343'.length) .should('have.prop', 'selectionEnd', '+1 212 343'.length) .type('{backspace}') .should('have.value', '+1 212 343-355') .should('have.prop', 'selectionStart', '+1 212 34'.length) .should('have.prop', 'selectionEnd', '+1 212 34'.length); }); it('+1 21|2 343-3355 => Backspace => +1 2|23 433-355', () => { cy.get('input') .should('be.visible') .should('have.value', '+1 212 343-3355') .focus() .type('{moveToEnd}') .type('{leftArrow}'.repeat('2 343-3355'.length)) .should('have.prop', 'selectionStart', '+1 21'.length) .should('have.prop', 'selectionEnd', '+1 21'.length) .type('{backspace}') .should('have.value', '+1 223 433-355') .should('have.prop', 'selectionStart', '+1 2'.length) .should('have.prop', 'selectionEnd', '+1 2'.length); }); }); }); describe('Strict mode (France)', () => { describe('Backspace on initial render', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions: maskitoPhoneOptionsGenerator({ countryIsoCode: 'FR', metadata, strict: true, }), initialValue: '+33 6 12-34-56-78', }, }); }); it('+33 6 12-34-56-78| => Backspace => +33 6 12-34-56-7|', () => { cy.get('input') .should('be.visible') .should('have.value', '+33 6 12-34-56-78') .focus() .type('{moveToEnd}') .type('{backspace}') .should('have.value', '+33 6 12-34-56-7') .should('have.prop', 'selectionStart', '+33 6 12-34-56-7'.length) .should('have.prop', 'selectionEnd', '+33 6 12-34-56-7'.length); }); it('+33 6 12-3|4-56-78 => Backspace => +33 6 12-|45-67-8', () => { cy.get('input') .should('be.visible') .should('have.value', '+33 6 12-34-56-78') .focus() .type('{moveToEnd}') .type('{leftArrow}'.repeat('4-56-78'.length)) .should('have.prop', 'selectionStart', '+33 6 12-3'.length) .should('have.prop', 'selectionEnd', '+33 6 12-3'.length) .type('{backspace}') .should('have.value', '+33 6 12-45-67-8') .should('have.prop', 'selectionStart', '+33 6 12-'.length) .should('have.prop', 'selectionEnd', '+33 6 12-'.length); }); it('+33 6 1|2-34-56-78 => Backspace => +33 6 |23-45-67-8', () => { cy.get('input') .should('be.visible') .should('have.value', '+33 6 12-34-56-78') .focus() .type('{moveToEnd}') .type('{leftArrow}'.repeat('2-34-56-78'.length)) .should('have.prop', 'selectionStart', '+33 6 1'.length) .should('have.prop', 'selectionEnd', '+33 6 1'.length) .type('{backspace}') .should('have.value', '+33 6 23-45-67-8') .should('have.prop', 'selectionStart', '+33 6 '.length) .should('have.prop', 'selectionEnd', '+33 6 '.length); }); it('+33 6 12-34|-56-78 => Backspace => +33 6 12-3|5-67-8', () => { cy.get('input') .should('be.visible') .should('have.value', '+33 6 12-34-56-78') .focus() .type('{moveToEnd}') .type('{leftArrow}'.repeat('-56-78'.length)) .should('have.prop', 'selectionStart', '+33 6 12-34'.length) .should('have.prop', 'selectionEnd', '+33 6 12-34'.length) .type('{backspace}') .should('have.value', '+33 6 12-35-67-8') .should('have.prop', 'selectionStart', '+33 6 12-3'.length) .should('have.prop', 'selectionEnd', '+33 6 12-3'.length); }); }); }); describe('Non-strict mode (United States)', () => { describe('Backspace on initial render', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions: maskitoPhoneOptionsGenerator({ countryIsoCode: 'US', metadata, strict: false, }), initialValue: '+1 212 343-3355', }, }); }); it('+1 212 343-3355| => Backspace => +1 212 343-335|', () => { cy.get('input') .should('be.visible') .should('have.value', '+1 212 343-3355') .focus() .type('{moveToEnd}') .type('{backspace}') .should('have.value', '+1 212 343-335') .should('have.prop', 'selectionStart', '+1 212 343-335'.length) .should('have.prop', 'selectionEnd', '+1 212 343-335'.length); }); it('+1 212 3|43-3355 => Backspace => +1 212 |433-355', () => { cy.get('input') .should('be.visible') .should('have.value', '+1 212 343-3355') .focus() .type('{moveToEnd}') .type('{leftArrow}'.repeat('43-3355'.length)) .should('have.prop', 'selectionStart', '+1 212 3'.length) .should('have.prop', 'selectionEnd', '+1 212 3'.length) .type('{backspace}') .should('have.value', '+1 212 433-355') .should('have.prop', 'selectionStart', '+1 212 '.length) .should('have.prop', 'selectionEnd', '+1 212 '.length); }); it('+1 212 343|-3355 => Backspace => +1 212 34|3-355', () => { cy.get('input') .should('be.visible') .should('have.value', '+1 212 343-3355') .focus() .type('{moveToEnd}') .type('{leftArrow}'.repeat('-3355'.length)) .should('have.prop', 'selectionStart', '+1 212 343'.length) .should('have.prop', 'selectionEnd', '+1 212 343'.length) .type('{backspace}') .should('have.value', '+1 212 343-355') .should('have.prop', 'selectionStart', '+1 212 34'.length) .should('have.prop', 'selectionEnd', '+1 212 34'.length); }); }); }); describe('Non-strict mode (France)', () => { describe('Backspace on initial render', () => { beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions: maskitoPhoneOptionsGenerator({ countryIsoCode: 'FR', metadata, strict: false, }), initialValue: '+33 6 12-34-56-78', }, }); }); it('+33 6 12-34-56-78| => Backspace => +33 6 12-34-56-7|', () => { cy.get('input') .should('be.visible') .should('have.value', '+33 6 12-34-56-78') .focus() .type('{moveToEnd}') .type('{backspace}') .should('have.value', '+33 6 12-34-56-7') .should('have.prop', 'selectionStart', '+33 6 12-34-56-7'.length) .should('have.prop', 'selectionEnd', '+33 6 12-34-56-7'.length); }); it('+33 6 12-3|4-56-78 => Backspace => +33 6 12-|45-67-8', () => { cy.get('input') .should('be.visible') .should('have.value', '+33 6 12-34-56-78') .focus() .type('{moveToEnd}') .type('{leftArrow}'.repeat('4-56-78'.length)) .should('have.prop', 'selectionStart', '+33 6 12-3'.length) .should('have.prop', 'selectionEnd', '+33 6 12-3'.length) .type('{backspace}') .should('have.value', '+33 6 12-45-67-8') .should('have.prop', 'selectionStart', '+33 6 12-'.length) .should('have.prop', 'selectionEnd', '+33 6 12-'.length); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/placeholder/placeholder-dispatch-input-events.cy.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import {maskitoDateOptionsGenerator, maskitoWithPlaceholder} from '@maskito/kit'; import {createOutputSpy} from 'cypress/angular'; import {TestInput} from '../utils'; describe('Placeholder | count number of dispatched input event', () => { describe('Card verification code example', () => { const maskitoOptions: MaskitoOptions = { ...maskitoWithPlaceholder('xxx'), mask: /^\d{0,3}$/, }; beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions, input: createOutputSpy('inputEvent'), }, }); }); it('Empty => Type 1 => 1|xx', () => { cy.get('input') .focus() .should('have.value', '') .type('1') .should('have.value', '1xx') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); cy.get('@inputEvent').should('have.callCount', 1); }); it('1|xx => Type 2 => 12|x', () => { cy.get('input') .type('12') .should('have.value', '12x') .should('have.prop', 'selectionStart', 2) .should('have.prop', 'selectionEnd', 2); cy.get('@inputEvent').should('have.callCount', 2); }); it('12|x => Type 3 => 123|', () => { cy.get('input') .type('123') .should('have.value', '123') .should('have.prop', 'selectionStart', 3) .should('have.prop', 'selectionEnd', 3); cy.get('@inputEvent').should('have.callCount', 3); }); it('123| => Backspace => 12|x', () => { cy.get('input') .type('123') .type('{backspace}') .should('have.value', '12x') .should('have.prop', 'selectionStart', 2) .should('have.prop', 'selectionEnd', 2); cy.get('@inputEvent').should('have.callCount', 4); }); it('12|x => Backspace => 1|xx', () => { cy.get('input') .type('12') .type('{backspace}') .should('have.value', '1xx') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); cy.get('@inputEvent').should('have.callCount', 3); }); it('1|xx => Backspace => |xxx', () => { cy.get('input') .type('1') .type('{backspace}') .should('have.value', 'xxx') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); cy.get('@inputEvent').should('have.callCount', 2); }); }); describe('With built-in Date mask (from @maskito/kit)', () => { const dateOptions = maskitoDateOptionsGenerator({ mode: 'dd/mm/yyyy', separator: '/', }); const {plugins, ...placeholderOptions} = maskitoWithPlaceholder('dd/mm/yyyy'); const maskitoOptions: MaskitoOptions = { ...dateOptions, plugins: plugins.concat(dateOptions.plugins), preprocessors: [ ...placeholderOptions.preprocessors, ...dateOptions.preprocessors, ], postprocessors: [ ...dateOptions.postprocessors, ...placeholderOptions.postprocessors, ], }; beforeEach(() => { cy.mount(TestInput, { componentProperties: { maskitoOptions, input: createOutputSpy('inputEvent'), }, }); }); ( [ // [typedValue, expectedValue, expectedCaretIndex] ['1', '1d/mm/yyyy', 1], ['12', '12/mm/yyyy', '12'.length], ['129', '12/09/yyyy', '12/09'.length], ['1292', '12/09/2yyy', '12/09/2'.length], ['12920', '12/09/20yy', '12/09/20'.length], ['129202', '12/09/202y', '12/09/202'.length], ['1292023', '12/09/2023', '12/09/2023'.length], ] as const ).forEach(([typedValue, expectedValue, expectedCaretIndex]) => { it(`Empty => Type ${typedValue} => ${expectedValue}`, () => { cy.get('input') .focus() .should('have.value', '') .type(typedValue) .should('have.value', expectedValue) .should('have.prop', 'selectionStart', expectedCaretIndex) .should('have.prop', 'selectionEnd', expectedCaretIndex); cy.get('@inputEvent').should('have.callCount', typedValue.length); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/placeholder/placeholder-has-same-characters-as-textfield.cy.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import {maskitoWithPlaceholder} from '@maskito/kit'; import {TestInput} from '../utils'; describe('Placeholder | intersects with some characters from textfield value', () => { const maskitoOptions: MaskitoOptions = { ...maskitoWithPlaceholder('DD/MMM/YYYY', true), mask: [ /\d/, /\d/, '/', /[a-z]/i, /[a-z]/i, /[a-z]/i, '/', /\d/, /\d/, /\d/, /\d/, ], }; beforeEach(() => { cy.mount(TestInput, {componentProperties: {maskitoOptions}}); }); it('Empty => focus => show placeholder', () => { cy.get('input').focus().should('have.value', 'DD/MMM/YYYY'); }); it('Empty => Type 31 => 31/MMM/YYYY', () => { cy.get('input') .type('31') .should('have.value', '31/MMM/YYYY') .should('have.prop', 'selectionStart', 2) .should('have.prop', 'selectionEnd', 2) .blur() .should('have.value', '31'); }); it('31|/MMM/YYYY => Type May => 31/May|/YYYY', () => { cy.get('input') .type('31') .should('have.value', '31/MMM/YYYY') .type('M') .should('have.value', '31/MMM/YYYY') .should('have.prop', 'selectionStart', '31/M'.length) .should('have.prop', 'selectionEnd', '31/M'.length) .type('ay') .should('have.value', '31/May/YYYY') .should('have.prop', 'selectionStart', '31/May'.length) .should('have.prop', 'selectionEnd', '31/May'.length) .blur() .should('have.value', '31/May'); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/placeholder/placeholder-partial-removal-on-blur.cy.ts ================================================ import {type MaskitoOptions, maskitoUpdateElement} from '@maskito/core'; import {maskitoEventHandler, maskitoWithPlaceholder} from '@maskito/kit'; import {TestInput} from '../utils'; describe('Placeholder | partial removal of placeholder characters on blur', () => { const PLACEHOLDER = 'xx xx xx xx##'; const {plugins, ...rest} = maskitoWithPlaceholder(PLACEHOLDER); const maskitoOptions: MaskitoOptions = { ...rest, mask: PLACEHOLDER.split('').map((x) => (x === 'x' || x === '#' ? /\d/ : x)), plugins: [ ...plugins, maskitoEventHandler('focus', (element) => { const value = element.value || ''; maskitoUpdateElement( element, `${value}${PLACEHOLDER.slice(value.length)}`, ); }), maskitoEventHandler('blur', (element) => maskitoUpdateElement(element, element.value.replaceAll('#', '')), ), ], }; beforeEach(() => { cy.mount(TestInput, {componentProperties: {maskitoOptions}}); }); it('Empty => focus => show full placeholder', () => { cy.get('input') .should('have.value', '') .focus() .should('have.value', PLACEHOLDER); }); [ {typedCharacters: '99', formattedValue: '99 xx xx xx##', caretIndex: '99'.length}, { typedCharacters: '9988', formattedValue: '99 88 xx xx##', caretIndex: '99 88'.length, }, { typedCharacters: '99887', formattedValue: '99 88 7x xx##', caretIndex: '99 88 7'.length, }, { typedCharacters: '998877', formattedValue: '99 88 77 xx##', caretIndex: '99 88 77'.length, }, { typedCharacters: '99887766', formattedValue: '99 88 77 66##', caretIndex: '99 88 77 66'.length, }, { typedCharacters: '998877665', formattedValue: '99 88 77 665#', caretIndex: '99 88 77 665'.length, }, { typedCharacters: '9988776654', formattedValue: '99 88 77 6654', caretIndex: '99 88 77 6654'.length, }, ].forEach(({typedCharacters, formattedValue, caretIndex}) => { it(`Only placeholder characters => Type ${typedCharacters} => ${formattedValue}`, () => { cy.get('input') .type(typedCharacters) .should('have.value', formattedValue) .should('have.prop', 'selectionStart', caretIndex) .should('have.prop', 'selectionEnd', caretIndex); }); const withoutHashtags = formattedValue.replaceAll('#', ''); it(`Focused textfield with ${formattedValue} => Blur => ${withoutHashtags}`, () => { cy.get('input') .type(typedCharacters) .blur() .should('have.value', withoutHashtags); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/react/async-predicate-options-race/reactApp.tsx ================================================ import type {MaskitoElementPredicate, MaskitoOptions} from '@maskito/core'; import {useMaskito} from '@maskito/react'; import {type ComponentType, useEffect, useState} from 'react'; import {AwesomeInput} from '../awesomeInput'; export const SWITCH_OPTIONS_TIME = 1_000; export const PREDICATE_RESOLVING_TIME = 2_000; const numberOptions: MaskitoOptions = {mask: /^\d+$/}; const engLettersOptions: MaskitoOptions = {mask: /^[a-z]+$/i}; const elementPredicate: MaskitoElementPredicate = async (element) => new Promise((resolve) => { setTimeout(() => resolve(element.querySelector('.real-input') as HTMLInputElement), PREDICATE_RESOLVING_TIME); }); export const App: ComponentType = () => { const [options, setOptions] = useState(numberOptions); const maskRef = useMaskito({options, elementPredicate}); useEffect(() => { setTimeout(() => { setOptions(engLettersOptions); }, SWITCH_OPTIONS_TIME); }, []); return ; }; ================================================ FILE: projects/demo-integrations/src/tests/component-testing/react/async-predicate-options-race/reactAsyncPredicateOptionsRace.cy.tsx ================================================ import {mount} from 'cypress/react'; import {App, PREDICATE_RESOLVING_TIME, SWITCH_OPTIONS_TIME} from './reactApp'; describe('React async predicate + maskitoOptions race', () => { beforeEach(() => { cy.clock(); mount(); cy.get('.real-input').should('be.visible').as('textfield'); }); it('can enter any value before no predicate is resolved', () => { cy.get('@textfield').focus().type('12abc3').should('have.value', '12abc3'); }); it('enabling of the first mask should be skipped if `options` were changed during resolving of element predicate', () => { cy.smartTick(PREDICATE_RESOLVING_TIME); // predicate is resolved only once for digit cases cy.get('@textfield').focus().type('12abc3').should('have.value', '12abc3'); }); it('only the last mask should be applied if [maskitoOptions] were changed during resolving of element predicates', () => { cy.smartTick(SWITCH_OPTIONS_TIME + PREDICATE_RESOLVING_TIME); // enough time to resolve element predicated for both cases cy.get('@textfield').focus().type('12abc3').should('have.value', 'abc'); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/react/async-predicates-race/reactApp.tsx ================================================ import {type MaskitoElementPredicate, maskitoInitialCalibrationPlugin, type MaskitoOptions} from '@maskito/core'; import {maskitoTimeOptionsGenerator} from '@maskito/kit'; import {useMaskito} from '@maskito/react'; import {type ComponentType, useEffect, useState} from 'react'; import {AwesomeInput} from '../awesomeInput'; const timeOptions = maskitoTimeOptionsGenerator({mode: 'HH:MM'}); const options: MaskitoOptions = { ...timeOptions, plugins: [...timeOptions.plugins, maskitoInitialCalibrationPlugin()], }; const correctPredicate: MaskitoElementPredicate = (host) => host.querySelector('.real-input')!; const wrongPredicate: MaskitoElementPredicate = (host) => host.querySelector('input')!; const longCorrectPredicate: MaskitoElementPredicate = async (host) => new Promise((resolve) => { setTimeout(() => { resolve(correctPredicate(host)); }, 2_000); }); const longInvalidPredicate: MaskitoElementPredicate = async (host) => new Promise((resolve) => { setTimeout(() => resolve(wrongPredicate(host)), 7_000); }); const fastValidPredicate: MaskitoElementPredicate = async (host) => new Promise((resolve) => { setTimeout(() => resolve(correctPredicate(host)), 500); }); export const App: ComponentType = () => { const [useCorrectPredicate, setUseCorrectPredicate] = useState(false); const inputRef2sec = useMaskito({options, elementPredicate: longCorrectPredicate}); const inputRefRaceCondition = useMaskito({ options, elementPredicate: useCorrectPredicate ? fastValidPredicate : longInvalidPredicate, }); useEffect(() => { setTimeout(() => { setUseCorrectPredicate(true); }, 2_000); }, []); return ( <> ); }; ================================================ FILE: projects/demo-integrations/src/tests/component-testing/react/async-predicates-race/reactAsyncPredicatesRace.cy.tsx ================================================ import {mount} from 'cypress/react'; import {App} from './reactApp'; describe('Async predicate works', () => { describe('Basic async predicate (it returns promise which resolves in 2s)', () => { beforeEach(() => { cy.clock(); mount(); cy.get('#async-predicate-2s-resolves').should('be.visible').as('input'); }); it('does not apply mask until `elementPredicate` resolves', () => { const typedText = 'Element predicate will resolves only in 2000 ms'; cy.get('@input').type(typedText); cy.smartTick(300); cy.get('@input').should('have.value', typedText); cy.smartTick(700); cy.get('@input').should('have.value', typedText); cy.smartTick(2000); cy.get('@input').should('have.value', '20:00'); }); it('rejects invalid character (after `elementPredicate` resolves)', () => { cy.smartTick(2_000); cy.get('@input').type('0taiga_family').should('have.value', '0'); }); it('automatically adds fixed characters (after `elementPredicate` resolves)', () => { cy.smartTick(2_000); cy.get('@input').type('1234').should('have.value', '12:34'); }); it('automatically pads time segments with zeroes for large digits (after `elementPredicate` resolves)', () => { cy.smartTick(2_000); cy.get('@input').type('99').should('have.value', '09:09'); }); }); describe('race condition check', () => { beforeEach(() => { cy.clock(); mount(); cy.get('#race-condition-check').should('be.visible').as('input'); }); it('does not apply mask until the first (fast valid) `elementPredicate` resolves', () => { const typedText = 'UseEffect will be triggered in 2s and predicate will resolve only in 0.5 seconds'; cy.get('@input').type(typedText); cy.smartTick(500); // Selected predicate is longInvalidPredicate (pending state) cy.get('@input').should('have.value', typedText); cy.smartTick(1000); // Selected predicate is longInvalidPredicate (still pending state) cy.get('@input').should('have.value', typedText); cy.smartTick(600); // Selected predicate is fastValidPredicate (pending state) cy.get('@input').should('have.value', typedText); cy.smartTick(1000); // Selected predicate is fastValidPredicate (promise is resolved) cy.get('@input').should('have.value', '20:5'); }); it('ignores the previous predicate if it resolves after the switching to new one', () => { cy.smartTick(10_000); cy.get('@input').type('taiga1134 family').should('have.value', '11:34'); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/react/awesomeInput.tsx ================================================ import {forwardRef, type InputHTMLAttributes} from 'react'; const hiddenInputStyles = {display: 'none'}; export const AwesomeInput = forwardRef>((props, ref) => (
)); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/react/change-event/changeEvent.cy.tsx ================================================ import type {MaskitoOptions} from '@maskito/core'; import {maskitoNumberOptionsGenerator} from '@maskito/kit'; import {useMaskito} from '@maskito/react'; import {mount} from 'cypress/react'; import {type ChangeEvent, type JSX, useCallback, useState} from 'react'; describe('React synthetic "onChange" event', () => { describe('uncontrolled input', () => { beforeEach(() => { const handler = cy.spy().as('handler'); const options = maskitoNumberOptionsGenerator({maximumFractionDigits: 2, thousandSeparator: ' '}); function App(): JSX.Element { const maskRef = useMaskito({options}); return ( handler(e.target.value)} /> ); } mount(); }); it('type valid value (no mask interference) => onChange is dispatched', () => { cy.get('input').type('0').should('have.value', '0'); cy.get('@handler').should('have.been.calledOnceWith', '0'); }); it('type invalid value (mask rejects it) => onChange is NOT dispatched', () => { cy.get('input').type('t').should('have.value', ''); cy.get('@handler').should('not.have.been.called'); }); it('type partially valid value (mask corrects it and patches textfield) => onChange is dispatched ONCE', () => { cy.get('input').type(',').should('have.value', '0.'); cy.get('@handler').should('have.been.calledOnceWith', '0.'); }); it('paste partially valid value (mask corrects it and patches textfield) => onChange is dispatched ONCE', () => { cy.get('input').focus().paste('123456,78').should('have.value', '123 456.78'); cy.get('@handler').should('have.been.calledOnceWith', '123 456.78'); }); }); describe('controlled input', () => { beforeEach(() => { const capitalize = (x: string): string => `${x.charAt(0).toUpperCase()}${x.slice(1)}`; const handler = cy.spy().as('handler'); const options: MaskitoOptions = { mask: /^[a-z]+$/i, postprocessors: [({value, selection}) => ({value: value.replaceAll('t', 'T'), selection})], }; function App(): JSX.Element { const maskRef = useMaskito({options}); const [value, setValue] = useState(''); const onChange = useCallback( ({target: {value}}: ChangeEvent) => { handler(value); setValue(capitalize(value)); }, [setValue, handler], ); return ( ); } mount(); }); it('type valid value (no mask interference) => onChange is dispatched', () => { cy.get('input').type('N').should('have.value', 'N'); cy.get('@handler').should('have.been.calledOnceWith', 'N'); cy.get('input').type('i').should('have.value', 'Ni'); cy.get('@handler').should('have.been.calledWith', 'Ni'); }); it('type invalid value (mask rejects it) => onChange is NOT dispatched', () => { cy.get('input').type('123').should('have.value', ''); cy.get('@handler').should('not.have.been.called'); }); it('type partially valid value (mask corrects it and patches textfield) => onChange is dispatched', () => { cy.get('input').type('Nikit').should('have.value', 'NikiT'); cy.get('@handler').should('have.been.calledWith', 'NikiT'); }); it('type partially valid value (state action corrects it and patches textfield) => onChange is dispatched with initial value', () => { cy.get('input').type('n').should('have.value', 'N'); cy.get('@handler').should('have.been.calledWith', 'n'); }); it('paste partially valid value (mask+state action correct it and patches textfield) => onChange is dispatched ONCE', () => { cy.get('input').focus().paste('nikita').should('have.value', 'NikiTa'); cy.get('@handler').should('have.been.calledOnceWith', 'nikiTa'); }); }); describe('controlled input with noop state handler', () => { beforeEach(() => { const handler = cy.spy().as('handler'); const options: MaskitoOptions = { mask: /^[a-z]+$/i, postprocessors: [({value, selection}) => ({value: value.toUpperCase(), selection})], }; function App(): JSX.Element { const maskRef = useMaskito({options}); const [value] = useState(''); return ( handler(e.target.value)} /> ); } mount(); }); it('type invalid value (mask rejects it) => onChange is NOT dispatched', () => { cy.get('input').type('123').should('have.value', ''); cy.get('@handler').should('not.have.been.called'); }); it('type valid value (no mask interference) => textfield value is still empty', () => { cy.get('input').type('T').should('have.value', ''); cy.get('@handler').should('have.been.calledWith', 'T'); }); it('type partially valid value (mask corrects it and patches textfield) => textfield value is still empty', () => { cy.get('input').type('t').should('have.value', ''); cy.get('@handler').should('have.been.calledWith', 'T'); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/component-testing/utils.ts ================================================ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, } from '@angular/core'; import {MaskitoDirective} from '@maskito/angular'; import {MASKITO_DEFAULT_ELEMENT_PREDICATE, type MaskitoOptions} from '@maskito/core'; @Component({ selector: 'test-input', imports: [MaskitoDirective], template: ` `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class TestInput { @Input() public initialValue = ''; @Input() public maskitoOptions: MaskitoOptions | null = null; @Input() public maskitoElementPredicate = MASKITO_DEFAULT_ELEMENT_PREDICATE; @Output() public readonly beforeinput = new EventEmitter(); @Output() public readonly input = new EventEmitter(); @Output() public readonly change = new EventEmitter(); @Input() public maxLength = Infinity; @Input() public type: HTMLInputElement['type'] = 'text'; } ================================================ FILE: projects/demo-integrations/src/tests/kit/date/date-basic.cy.ts ================================================ import {DemoPath} from '@demo/constants'; import {BROWSER_SUPPORTS_REAL_EVENTS} from '../../../support/constants'; import {repeatKey} from '../../utils'; describe('Date', () => { describe('Basic', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?mode=dd%2Fmm%2Fyyyy`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); describe('basic typing (1 character per keydown)', () => { const tests = [ // [Typed value, Masked value, caretIndex] ['1', '1', 1], ['12', '12', '12'.length], ['121', '12.1', '12.1'.length], ['1211', '12.11', '12.11'.length], ['0', '0', 1], ['00', '0', '0'.length], ] as const; tests.forEach(([typedValue, maskedValue, caretIndex]) => { it(`Type "${typedValue}" => "${maskedValue}"`, () => { cy.get('@input') .type(typedValue) .should('have.value', maskedValue) .should('have.prop', 'selectionStart', caretIndex) .should('have.prop', 'selectionEnd', caretIndex); }); }); it('Type "9" => "09|"', () => { cy.get('@input') .type('9') .should('have.value', '09') .should('have.prop', 'selectionStart', '09'.length) .should('have.prop', 'selectionEnd', '09'.length); }); it('27| => type 9 => 27.09|', () => { cy.get('@input') .type('27') .should('have.value', '27') .should('have.prop', 'selectionStart', '27'.length) .should('have.prop', 'selectionEnd', '27'.length) .type('9') .should('have.value', '27.09') .should('have.prop', 'selectionStart', '27.09'.length) .should('have.prop', 'selectionEnd', '27.09'.length); }); it('3| => Type 7 => 03.07|', () => { cy.get('@input') .type('3') .should('have.value', '3') .should('have.prop', 'selectionStart', '3'.length) .should('have.prop', 'selectionEnd', '3'.length) .type('7') .should('have.value', '03.07') .should('have.prop', 'selectionStart', '03.07'.length) .should('have.prop', 'selectionEnd', '03.07'.length); }); it('year less than 100', () => { cy.get('@input') .type('10100012') .should('have.value', '10.10.0012') .should('have.prop', 'selectionStart', '10.10.0012'.length) .should('have.prop', 'selectionEnd', '10.10.0012'.length); }); }); describe('basic erasing (value = "10.11.2002" & caret is placed after the last value)', () => { beforeEach(() => { cy.get('@input').type('10112002'); }); const tests = [ // [How many times "Backspace"-key was pressed, caretPosition, Masked value] [1, '10.11.200'.length, '10.11.200'], [2, '10.11.20'.length, '10.11.20'], [3, '10.11.2'.length, '10.11.2'], [4, '10.11'.length, '10.11'], ] as const; tests.forEach(([n, caretIndex, maskedValue]) => { it(`Backspace x${n} => "${maskedValue}"`, () => { cy.get('@input') .type('{backspace}'.repeat(n)) .should('have.value', maskedValue) .should('have.prop', 'selectionStart', caretIndex) .should('have.prop', 'selectionEnd', caretIndex); }); }); it('Delete => no value change && no caret index change', () => { cy.get('@input') .type('{del}') .should('have.value', '10.11.2002') .should('have.prop', 'selectionStart', '10.11.2002'.length) .should('have.prop', 'selectionEnd', '10.11.2002'.length); }); it('Type `deleteWordBackward` of `InputEvent` works', () => { cy.get('@input') .type('{ctrl+backspace}') .should('have.value', '') .should('have.prop', 'selectionStart', ''.length) .should('have.prop', 'selectionEnd', ''.length); }); it('Type `deleteWordForward` of `InputEvent` works', () => { cy.get('@input') .type('{moveToStart}') .type('{ctrl+del}') .should('have.value', '') .should('have.prop', 'selectionStart', ''.length) .should('have.prop', 'selectionEnd', ''.length); }); it('Type `deleteSoftLineForward` of `InputEvent` works', () => { cy.get('@input') .type('{moveToStart}') .trigger('beforeinput', {inputType: 'deleteSoftLineForward'}) .trigger('input', {inputType: 'deleteSoftLineForward'}) .should('have.value', '') .should('have.prop', 'selectionStart', ''.length) .should('have.prop', 'selectionEnd', ''.length); }); it('Type `deleteSoftLineBackward` of `InputEvent` works', () => { cy.get('@input') .trigger('beforeinput', {inputType: 'deleteSoftLineBackward'}) .trigger('input', {inputType: 'deleteSoftLineBackward'}) .should('have.value', '') .should('have.prop', 'selectionStart', ''.length) .should('have.prop', 'selectionEnd', ''.length); }); }); describe('Editing somewhere in the middle of a value (NOT the last character)', () => { it('01.1|2.1998 => Backspace => 01.|02.1998 => Type "1" => 01.1|2.1998', () => { cy.get('@input') .type('01121998') .type('{leftArrow}'.repeat(6)) .should('have.prop', 'selectionStart', '01.1'.length) .should('have.prop', 'selectionEnd', '01.1'.length) .type('{backspace}') .should('have.value', '01.02.1998') .should('have.prop', 'selectionStart', '01.'.length) .should('have.prop', 'selectionEnd', '01.'.length) .type('1') .should('have.value', '01.12.1998') .should('have.prop', 'selectionStart', '01.1'.length) .should('have.prop', 'selectionEnd', '01.1'.length); }); it('12|.01.2008 => Backspace => 1|0.01.2008 => Type "1" => 11|.01.2008', () => { cy.get('@input') .type('12012008') .type('{leftArrow}'.repeat('.01.2008'.length)) .should('have.prop', 'selectionStart', '12'.length) .should('have.prop', 'selectionEnd', '12'.length) .type('{backspace}') .should('have.value', '10.01.2008') .should('have.prop', 'selectionStart', '1'.length) .should('have.prop', 'selectionEnd', '1'.length) .type('1') .should('have.value', '11.01.2008') .should('have.prop', 'selectionStart', '11:'.length) .should('have.prop', 'selectionEnd', '11:'.length); }); it('12.|12.2010 => Type "9" => 12.09.|2010', () => { cy.get('@input') .type('12122010') .type('{leftArrow}'.repeat('12.2010'.length)) .should('have.prop', 'selectionStart', '12.'.length) .should('have.prop', 'selectionEnd', '12.'.length) .type('9') .should('have.value', '12.09.2010') .should('have.prop', 'selectionStart', '12.09.'.length) .should('have.prop', 'selectionEnd', '12.09.'.length); }); it('|15.01.2012 => Type "3" => 3|0.01.2012', () => { cy.get('@input') .type('15012012') .type('{moveToStart}') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0) .type('3') .should('have.value', '30.01.2012') .should('have.prop', 'selectionStart', '3'.length) .should('have.prop', 'selectionEnd', '3'.length); }); it('02|.01.2008 => Backspace => 0|1.01.2008 => Type "5" => 05|.01.2008', () => { cy.get('@input') .type('02012008') .type('{leftArrow}'.repeat('.01.2008'.length)) .should('have.prop', 'selectionStart', '02'.length) .should('have.prop', 'selectionEnd', '02'.length) .type('{backspace}') .should('have.value', '01.01.2008') .should('have.prop', 'selectionStart', '0'.length) .should('have.prop', 'selectionEnd', '0'.length) .type('5') .should('have.value', '05.01.2008') .should('have.prop', 'selectionStart', '05.'.length) .should('have.prop', 'selectionEnd', '05.'.length); }); }); describe('Fixed values', () => { it('Press Backspace after fixed value => no value change => move caret to the left', () => { cy.get('@input') .type('10122022') .type('{leftArrow}'.repeat('2022'.length)) .should('have.prop', 'selectionStart', '10.12.'.length) .should('have.prop', 'selectionEnd', '10.12.'.length) .type('{backspace}') .should('have.value', '10.12.2022') .should('have.prop', 'selectionStart', '10.12'.length) .should('have.prop', 'selectionEnd', '10.12'.length); }); it('Press Delete after fixed value => no value change => move caret to the right', () => { cy.get('@input') .type('10122022') .type('{leftArrow}'.repeat('.2022'.length)) .should('have.prop', 'selectionStart', '10.12'.length) .should('have.prop', 'selectionEnd', '10.12'.length) .type('{del}') .should('have.value', '10.12.2022') .should('have.prop', 'selectionStart', '10.12.'.length) .should('have.prop', 'selectionEnd', '10.12.'.length); }); }); describe('Text selection', () => { describe('Select range and press Backspace', () => { it( '10.|12|.2022 => Backspace => 10.|01.2022', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('10122022') .type('{leftArrow}'.repeat('.2022'.length)) .realPress([ 'Shift', ...repeatKey('ArrowLeft', '12'.length), 'Backspace', ]); cy.get('@input') .should('have.value', '10.01.2022') .should('have.prop', 'selectionStart', '10.'.length) .should('have.prop', 'selectionEnd', '10.'.length); }, ); it( '1|1.1|1.2011 => Backspace => 1|0.01.2011', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('11112011') .type('{leftArrow}'.repeat('1.2011'.length)) .realPress([ 'Shift', ...repeatKey('ArrowLeft', '1.1'.length), 'Backspace', ]); cy.get('@input') .should('have.value', '10.01.2011') .should('have.prop', 'selectionStart', '1'.length) .should('have.prop', 'selectionEnd', '1'.length); }, ); }); describe('Select range and press new digit', () => { it( '|12|.11.2022 => Press 3 => 3|0.11.2022', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('12112022') .type('{leftArrow}'.repeat('.11.2022'.length)) .realPress(['Shift', ...repeatKey('ArrowLeft', '12'.length)]); cy.get('@input') .type('3') .should('have.value', '30.11.2022') .should('have.prop', 'selectionStart', '3'.length) .should('have.prop', 'selectionEnd', '3'.length); }, ); it( '|12|.11.2022 => Press 0 => 0|1.11.2022', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('12112022') .type('{leftArrow}'.repeat('.11.2022'.length)) .realPress(['Shift', ...repeatKey('ArrowLeft', '12'.length)]); cy.get('@input') .type('0') .should('have.value', '01.11.2022') .should('have.prop', 'selectionStart', '0'.length) .should('have.prop', 'selectionEnd', '0'.length); }, ); it('|12.11.2022| => Press 0 => 0|', () => { cy.get('@input') .type('12112022') .type('{selectAll}') .type('0') .should('have.value', '0') .should('have.prop', 'selectionStart', '0'.length) .should('have.prop', 'selectionEnd', '0'.length); }); it('|12.11.2022| => Press 1 => 1|', () => { cy.get('@input') .type('12112022') .type('{selectAll}') .type('1') .should('have.value', '1') .should('have.prop', 'selectionStart', '1'.length) .should('have.prop', 'selectionEnd', '1'.length); }); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/date/date-fullwidth-to-halfwidth.cy.ts ================================================ import {DemoPath} from '@demo/constants'; /* NOTE: yyyy/mm/dd is very common in Japan */ describe('Date', () => { describe('Full width character parsing', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?mode=yyyy%2Fmm%2Fdd&separator=%2F`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); describe('Accepts all of full width characters', () => { it('20191231 => 2019/12/31', () => { cy.get('@input') .type('20191231') .should('have.value', '2019/12/31') .should('have.prop', 'selectionStart', '2019/12/31'.length) .should('have.prop', 'selectionEnd', '2019/12/31'.length); }); }); describe('pads digits with zero if date segment exceeds its max possible value', () => { // NOTE: months can be > 12 => pads the first 2 with zero it('20102| => type 9 => 2010/02|', () => { cy.get('@input') .type('20102') .should('have.value', '2010/02') .should('have.prop', 'selectionStart', '2010/02'.length) .should('have.prop', 'selectionEnd', '2010/02'.length) .type('9') .should('have.value', '2010/02/09') .should('have.prop', 'selectionStart', '2010/02/09'.length) .should('have.prop', 'selectionEnd', '2010/02/09'.length); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/date/date-min-max.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Date', () => { describe('Max date', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?max=2020-05-05`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('Input less than max value', () => { cy.get('@input') .type('31122019') .should('have.value', '31.12.2019') .should('have.prop', 'selectionStart', '31.12.2019'.length) .should('have.prop', 'selectionEnd', '31.12.2019'.length); }); it('05.12.202| => type 5 => 05.05.2020 (max value)', () => { cy.get('@input') .type('0512202') .should('have.value', '05.12.202') .should('have.prop', 'selectionStart', '05.12.202'.length) .should('have.prop', 'selectionEnd', '05.12.202'.length) .type('5') .should('have.value', '05.05.2020') .should('have.prop', 'selectionStart', '05.05.2020'.length) .should('have.prop', 'selectionEnd', '05.05.2020'.length); }); it('0|3.05.2020 => type 7 => 05|.05.2020 (max value)', () => { cy.get('@input') .type('03052020') .type('{moveToStart}{rightArrow}') .type('7') .should('have.value', '05.05.2020') .should('have.prop', 'selectionStart', '05.'.length) .should('have.prop', 'selectionEnd', '05.'.length); }); it('03.0|5.2020 => type 7 => 05.05|.2020 (max value)', () => { cy.get('@input') .type('03052020') .type('{leftArrow}'.repeat('5.2020'.length)) .type('7') .should('have.value', '05.05.2020') .should('have.prop', 'selectionStart', '05.05.'.length) .should('have.prop', 'selectionEnd', '05.05.'.length); }); }); describe('Min date', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?min=2020-05-05`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('Input more than min value', () => { cy.get('@input') .type('20102022') .should('have.value', '20.10.2022') .should('have.prop', 'selectionStart', '20.10.2022'.length) .should('have.prop', 'selectionEnd', '20.10.2022'.length); }); it('05.12.201| => type 9 => 05.05.2020 (min value)', () => { cy.get('@input') .type('0512201') .should('have.value', '05.12.201') .should('have.prop', 'selectionStart', '05.12.201'.length) .should('have.prop', 'selectionEnd', '05.12.201'.length) .type('9') .should('have.value', '05.05.2020') .should('have.prop', 'selectionStart', '05.05.2020'.length) .should('have.prop', 'selectionEnd', '05.05.2020'.length); }); it('0|7.05.2020 => type 2 => 05|.05.2020 (min value)', () => { cy.get('@input') .type('07052020') .type('{moveToStart}{rightArrow}') .type('2') .should('have.value', '05.05.2020') .should('have.prop', 'selectionStart', '05.'.length) .should('have.prop', 'selectionEnd', '05.'.length); }); it('03.0|6.2020 => type 2 => 05.05|.2020 (min value)', () => { cy.get('@input') .type('03062020') .type('{leftArrow}'.repeat('6.2020'.length)) .type('2') .should('have.value', '05.05.2020') .should('have.prop', 'selectionStart', '05.05.'.length) .should('have.prop', 'selectionEnd', '05.05.'.length); }); }); describe('Min date 2000', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?min=2000-01-01`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('Input less than min value', () => { cy.get('@input') .type('10101997') .should('have.value', '01.01.2000') .should('have.prop', 'selectionStart', '01.01.2000'.length) .should('have.prop', 'selectionEnd', '01.01.2000'.length); }); }); describe('Max date, shortened year', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?max=2020-05-05&mode=mm%2Fyy&separator=%2F`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('Input less than max value', () => { cy.get('@input') .type('0419') .should('have.value', '04/19') .should('have.prop', 'selectionStart', '04/19'.length) .should('have.prop', 'selectionEnd', '04/19'.length); }); it('03/2| => type 9 => 05/20 (max value)', () => { cy.get('@input') .type('032') .should('have.value', '03/2') .should('have.prop', 'selectionStart', '03/2'.length) .should('have.prop', 'selectionEnd', '03/2'.length) .type('9') .should('have.value', '05/20') .should('have.prop', 'selectionStart', '05/20'.length) .should('have.prop', 'selectionEnd', '05/20'.length); }); }); describe('Min date, shortened year', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?min=2020-05-05&mode=mm%2Fyy&separator=%2F`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('Input more than min value', () => { cy.get('@input') .type('0625') .should('have.value', '06/25') .should('have.prop', 'selectionStart', '06/25'.length) .should('have.prop', 'selectionEnd', '06/25'.length); }); it('03/1| => type 9 => 05/20 (min value)', () => { cy.get('@input') .type('031') .should('have.value', '03/1') .should('have.prop', 'selectionStart', '03/1'.length) .should('have.prop', 'selectionEnd', '03/1'.length) .type('9') .should('have.value', '05/20') .should('have.prop', 'selectionStart', '05/20'.length) .should('have.prop', 'selectionEnd', '05/20'.length); }); }); describe('Min date, yyyy', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?min=2020-05-05&mode=yyyy`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('Input less than min value', () => { cy.get('@input') .type('2019') .should('have.value', '2020') .should('have.prop', 'selectionStart', '2020'.length) .should('have.prop', 'selectionEnd', '2020'.length); }); it('Input more than min value', () => { cy.get('@input') .type('2021') .should('have.value', '2021') .should('have.prop', 'selectionStart', '2021'.length) .should('have.prop', 'selectionEnd', '2021'.length); }); }); describe('Max date, yyyy', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?max=2020-05-05&mode=yyyy`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('Input less than max value', () => { cy.get('@input') .type('2019') .should('have.value', '2019') .should('have.prop', 'selectionStart', '2019'.length) .should('have.prop', 'selectionEnd', '2019'.length); }); it('Input more than max value', () => { cy.get('@input') .type('2021') .should('have.value', '2020') .should('have.prop', 'selectionStart', '2020'.length) .should('have.prop', 'selectionEnd', '2020'.length); }); }); describe('Min date, mm/yyyy', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?min=2020-05-05&mode=mm%2Fyyyy&separator=%2F`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('Input less than min value', () => { cy.get('@input') .type('042019') .should('have.value', '05/2020') .should('have.prop', 'selectionStart', '05/2020'.length) .should('have.prop', 'selectionEnd', '05/2020'.length); }); it('Input more than min value', () => { cy.get('@input') .type('062020') .should('have.value', '06/2020') .should('have.prop', 'selectionStart', '06/2020'.length) .should('have.prop', 'selectionEnd', '06/2020'.length); }); }); describe('Max date, mm/yyyy', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?max=2020-05-05&mode=mm%2Fyyyy&separator=%2F`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('Input less than max value', () => { cy.get('@input') .type('042019') .should('have.value', '04/2019') .should('have.prop', 'selectionStart', '04/2019'.length) .should('have.prop', 'selectionEnd', '04/2019'.length); }); it('Input more than max value', () => { cy.get('@input') .type('062020') .should('have.value', '05/2020') .should('have.prop', 'selectionStart', '05/2020'.length) .should('have.prop', 'selectionEnd', '05/2020'.length); }); }); describe('Min date yyyy/mm', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?min=2020-05-05&mode=yyyy%2Fmm&separator=%2F`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('Input less than min value', () => { cy.get('@input') .type('201904') .should('have.value', '2020/05') .should('have.prop', 'selectionStart', '2020/05'.length) .should('have.prop', 'selectionEnd', '2020/05'.length); }); it('Input more than min value', () => { cy.get('@input') .type('202106') .should('have.value', '2021/06') .should('have.prop', 'selectionStart', '2021/06'.length) .should('have.prop', 'selectionEnd', '2021/06'.length); }); }); describe('Max date yyyy/mm', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?max=2020-05-05&mode=yyyy%2Fmm&separator=%2F`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('Input less than max value', () => { cy.get('@input') .type('201904') .should('have.value', '2019/04') .should('have.prop', 'selectionStart', '2019/04'.length) .should('have.prop', 'selectionEnd', '2019/04'.length); }); it('Input more than max value', () => { cy.get('@input') .type('202106') .should('have.value', '2020/05') .should('have.prop', 'selectionStart', '2020/05'.length) .should('have.prop', 'selectionEnd', '2020/05'.length); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/date/date-mode.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Date', () => { describe('Mode', () => { describe('yyyy/mm/dd', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?mode=yyyy%2Fmm%2Fdd`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it(' "yyyy/mm/dd" => 2019.12.31', () => { cy.get('@input') .type('20193') .should('have.value', '2019.03') .should('have.prop', 'selectionStart', '2019.03'.length) .should('have.prop', 'selectionEnd', '2019.03'.length) .type('22') .should('have.value', '2019.03.22') .should('have.prop', 'selectionStart', '2019.03.22'.length) .should('have.prop', 'selectionEnd', '2019.03.22'.length); }); }); describe('mm/dd/yyyy', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?mode=mm%2Fdd%2Fyyyy`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it(' "mm/dd/yyyy" => 03.12.2020', () => { cy.get('@input') .type('312') .should('have.value', '03.12') .should('have.prop', 'selectionStart', '03.12'.length) .should('have.prop', 'selectionEnd', '03.12'.length) .type('2022') .should('have.value', '03.12.2022') .should('have.prop', 'selectionStart', '03.12.2022'.length) .should('have.prop', 'selectionEnd', '03.12.2022'.length); }); }); describe('mm/yy', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?mode=mm%2Fyy`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('"mm/yy" => 03.20', () => { cy.get('@input') .type('3') .should('have.value', '03') .should('have.prop', 'selectionStart', '03'.length) .should('have.prop', 'selectionEnd', '03'.length) .type('20') .should('have.value', '03.20') .should('have.prop', 'selectionStart', '03.20'.length) .should('have.prop', 'selectionEnd', '03.20'.length); }); it('"mm/yy" => 03.22', () => { cy.get('@input') .type('0') .should('have.value', '0') .should('have.prop', 'selectionStart', '0'.length) .should('have.prop', 'selectionEnd', '0'.length) .type('322') .should('have.value', '03.22') .should('have.prop', 'selectionStart', '03.22'.length) .should('have.prop', 'selectionEnd', '03.22'.length); }); it('"mm/yy" => 12.04', () => { cy.get('@input') .type('1.2.') .should('have.value', '12.') .type('04') .should('have.value', '12.04'); }); }); describe('mm/yyyy', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?mode=mm/yyyy`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('"mm/yyyy" => 02.2025', () => { cy.get('@input') .type('2') .should('have.value', '02') .should('have.prop', 'selectionStart', '02'.length) .should('have.prop', 'selectionEnd', '02'.length) .type('2025') .should('have.value', '02.2025') .should('have.prop', 'selectionStart', '02.2025'.length) .should('have.prop', 'selectionEnd', '02.2025'.length); }); it('"mm/yyyy" => 11.1999', () => { cy.get('@input') .type('1') .should('have.value', '1') .should('have.prop', 'selectionStart', '1'.length) .should('have.prop', 'selectionEnd', '1'.length) .type('11999') .should('have.value', '11.1999') .should('have.prop', 'selectionStart', '11.1999'.length) .should('have.prop', 'selectionEnd', '11.1999'.length); }); it('"mm/yyyy" => 05.2004', () => { cy.get('@input') .type('0.') .should('have.value', '0') .type('5') .should('have.value', '05') .type('.2004') .should('have.value', '05.2004'); }); }); describe('yyyy/mm', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?mode=yyyy/mm`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('"yyyy/mm" => 2025.02', () => { cy.get('@input') .type('2025') .should('have.value', '2025') .should('have.prop', 'selectionStart', '2025'.length) .should('have.prop', 'selectionEnd', '2025'.length) .type('2') .should('have.value', '2025.02') .should('have.prop', 'selectionStart', '2025.02'.length) .should('have.prop', 'selectionEnd', '2025.02'.length); }); it('"yyyy/mm" => 1999.11', () => { cy.get('@input') .type('19991') .should('have.value', '1999.1') .should('have.prop', 'selectionStart', '1999.1'.length) .should('have.prop', 'selectionEnd', '1999.1'.length) .type('1') .should('have.value', '1999.11') .should('have.prop', 'selectionStart', '1999.11'.length) .should('have.prop', 'selectionEnd', '1999.11'.length); }); it('"yyyy/mm" => 2004.05', () => { cy.get('@input') .type('20.0.4') .should('have.value', '2004') .type('.') .should('have.value', '2004.') .type('05') .should('have.value', '2004.05'); }); }); describe('yyyy', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?mode=yyyy`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('"yyyy" => 2025', () => { cy.get('@input') .type('2') .should('have.value', '2') .should('have.prop', 'selectionStart', '2'.length) .should('have.prop', 'selectionEnd', '2'.length) .type('025') .should('have.value', '2025') .should('have.prop', 'selectionStart', '2025'.length) .should('have.prop', 'selectionEnd', '2025'.length); }); }); describe('dd/mm', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?mode=dd%2Fmm`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('"dd/mm" => 14.07', () => { cy.get('@input') .type('1407') .should('have.value', '14.07') .should('have.prop', 'selectionStart', '14.07'.length) .should('have.prop', 'selectionEnd', '14.07'.length); }); it('"dd/mm" => 01.01', () => { cy.get('@input') .type('0101') .should('have.value', '01.01') .should('have.prop', 'selectionStart', '01.11'.length) .should('have.prop', 'selectionEnd', '01.11'.length); }); it('"dd/mm" => 05.11', () => { cy.get('@input') .type('511') .should('have.value', '05.11') .should('have.prop', 'selectionStart', '05.11'.length) .should('have.prop', 'selectionEnd', '05.11'.length); }); it('"dd/mm" => 05.05', () => { cy.get('@input') .type('55') .should('have.value', '05.05') .should('have.prop', 'selectionStart', '05.05'.length) .should('have.prop', 'selectionEnd', '05.05'.length); }); it('dd/mm" => 01.05', () => { cy.get('@input') .type('3104') .should('have.value', '01.05') .should('have.prop', 'selectionStart', '01.05'.length) .should('have.prop', 'selectionEnd', '01.05'.length); }); }); describe('mm/dd', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?mode=mm%2Fdd`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('"mm/dd" => 02.29', () => { cy.get('@input') .type('0229') .should('have.value', '02.29') .should('have.prop', 'selectionStart', '02.29'.length) .should('have.prop', 'selectionEnd', '02.29'.length); }); it('"mm/dd" => 01.01', () => { cy.get('@input') .type('0101') .should('have.value', '01.01') .should('have.prop', 'selectionStart', '01.01'.length) .should('have.prop', 'selectionEnd', '01.01'.length); }); it('"mm/dd" => 09.12', () => { cy.get('@input') .type('912') .should('have.value', '09.12') .should('have.prop', 'selectionStart', '09.12'.length) .should('have.prop', 'selectionEnd', '09.12'.length); }); it('"mm/dd" => 09.09', () => { cy.get('@input') .type('99') .should('have.value', '09.09') .should('have.prop', 'selectionStart', '09.09'.length) .should('have.prop', 'selectionEnd', '09.09'.length); }); it('dd/mm" => 05.01', () => { cy.get('@input') .type('431') .should('have.value', '05.01') .should('have.prop', 'selectionStart', '05.01'.length) .should('have.prop', 'selectionEnd', '05.01'.length); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/date/date-segments-zero-padding.cy.ts ================================================ import {DemoPath} from '@demo/constants'; import {BROWSER_SUPPORTS_REAL_EVENTS} from '../../../support/constants'; describe('Date | Date segments zero padding (pads digits with zero if date segment exceeds its max possible value)', () => { describe('[mode]="dd.mm.yyyy"', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?mode=dd%2Fmm%2Fyyyy&separator=.`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); describe('pads the 1st digit > 3 with zero for days', () => { [0, 1, 2, 3].forEach((digit) => { it(`Type ${digit} => ${digit}`, () => { cy.get('@input') .type(`${digit}`) .should('have.value', `${digit}`) .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); }); [4, 5, 6, 7, 8, 9].forEach((digit) => { it(`Type ${digit} => 0${digit}`, () => { cy.get('@input') .type(`${digit}`) .should('have.value', `0${digit}`) .should('have.prop', 'selectionStart', `0${digit}`.length) .should('have.prop', 'selectionEnd', `0${digit}`.length); }); }); it( '|11|.11.2011 => Type 7 => 07.|11.2011', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('11.11.2011') .type('{moveToStart}') .realPress(['Shift', 'ArrowRight', 'ArrowRight']); cy.get('@input') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', '07'.length) .type('7') .should('have.value', '07.11.2011') .should('have.prop', 'selectionStart', '07.'.length) .should('have.prop', 'selectionEnd', '07.'.length); }, ); }); describe('pads the 1st digit > 1 with zero for months', () => { [0, 1].forEach((digit) => { it(`Type 01.${digit} => 01.${digit}`, () => { cy.get('@input') .type(`01${digit}`) .should('have.value', `01.${digit}`) .should('have.prop', 'selectionStart', `01.${digit}`.length) .should('have.prop', 'selectionEnd', `01.${digit}`.length); }); }); [2, 3, 4, 5, 6, 7, 8, 9].forEach((digit) => { it(`Type 01.${digit} => 01.0${digit}`, () => { cy.get('@input') .type(`01${digit}`) .should('have.value', `01.0${digit}`) .should('have.prop', 'selectionStart', `01.0${digit}`.length) .should('have.prop', 'selectionEnd', `01.0${digit}`.length); }); }); it( '11.|11|.2011 => Type 2 => 11.02.|2011', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('11.11.2011') .type('{moveToEnd}') .type('{leftArrow}'.repeat('.2011'.length)) .realPress(['Shift', 'ArrowLeft', 'ArrowLeft']); cy.get('@input') .should('have.prop', 'selectionStart', '11.'.length) .should('have.prop', 'selectionEnd', '11.11'.length) .type('2') .should('have.value', '11.02.2011') .should('have.prop', 'selectionStart', '01.02.'.length) .should('have.prop', 'selectionEnd', '01.02.'.length); }, ); }); describe('if users enters two digits and its combination exceeds the first (and only first!) non-year date segment - pad the first digit with zero', () => { it('Empty input => Type 35 => 03.05|', () => { cy.get('@input') .type('35') .should('have.value', '03.05') .should('have.prop', 'selectionStart', '03.05'.length) .should('have.prop', 'selectionEnd', '03.05'.length); }); it('|19.01.2025 => Type 3 => 3|0.01.2025', () => { cy.get('@input') .type('19012025') .should('have.value', '19.01.2025') .type('{moveToStart}') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0) .type('3') .should('have.value', '30.01.2025') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('31.1| => Type 3 => 31.1|', () => { cy.get('@input') .type('311') .should('have.value', '31.1') .type('3') .should('have.value', '31.1') .should('have.prop', 'selectionStart', '31.1'.length) .should('have.prop', 'selectionEnd', '31.1'.length); }); }); }); describe('[mode]="mm/dd/yyyy"', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?mode=mm%2Fdd%2Fyyyy&separator=/`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); describe('handles month value exceeding maximum', () => { it('Type 13 => 01/3', () => { cy.get('@input') .type('13') .should('have.value', '01/3') .should('have.prop', 'selectionStart', '01/3'.length) .should('have.prop', 'selectionEnd', '01/3'.length); }); }); }); describe('[mode]="yyyy/mm/dd"', () => { beforeEach(() => { cy.visit( `/${DemoPath.Date}/API?mode=${encodeURIComponent('yyyy/mm/dd')}&separator=${encodeURIComponent('/')}`, ); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); describe('if users enters two digits and its combination exceeds the first (and only first!) non-year date segment - pad the first digit with zero', () => { it('2025 => Type 35 => 2025/03/05|', () => { cy.get('@input') .type('202535') .should('have.value', '2025/03/05') .should('have.prop', 'selectionStart', '2025/03/05'.length) .should('have.prop', 'selectionEnd', '2025/03/05'.length); }); it('2025/|09/30 => Type 1 => 2025/1|0/30', () => { cy.get('@input') .type('2025930') .should('have.value', '2025/09/30') .type('{leftArrow}'.repeat('09/30'.length)) .should('have.prop', 'selectionStart', '2025/'.length) .should('have.prop', 'selectionEnd', '2025/'.length) .type('1') .should('have.value', '2025/10/30') .should('have.prop', 'selectionStart', '2025/1'.length) .should('have.prop', 'selectionEnd', '2025/1'.length); }); it('2025.01.3| => Type 5 => 2025.01.3|', () => { cy.get('@input') .type('2025013') .should('have.value', '2025/01/3') .type('5') .should('have.value', '2025/01/3') .should('have.prop', 'selectionStart', '2025/01/3'.length) .should('have.prop', 'selectionEnd', '2025/01/3'.length); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/date/date-separator.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Date', () => { describe('Separator', () => { describe('/', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?separator=/`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it(' "/" => 31/12/2019', () => { cy.get('@input') .type('3112') .should('have.value', '31/12') .should('have.prop', 'selectionStart', '31/12'.length) .should('have.prop', 'selectionEnd', '31/12'.length) .type('2019') .should('have.value', '31/12/2019') .should('have.prop', 'selectionStart', '31/12/2019'.length) .should('have.prop', 'selectionEnd', '31/12/2019'.length); }); it('input "/" separator"', () => { cy.get('@input') .type('31/') .should('have.value', '31/') .should('have.prop', 'selectionStart', '31/'.length) .should('have.prop', 'selectionEnd', '31/'.length); }); it('input separator "/" is not allowed', () => { cy.get('@input') .type('3/') .should('have.value', '3') .should('have.prop', 'selectionStart', '3'.length) .should('have.prop', 'selectionEnd', '3'.length); }); it('Input separator "-" in separator mode "/" is not allowed', () => { cy.get('@input') .type('31-') .should('have.value', '31') .should('have.prop', 'selectionStart', '31'.length) .should('have.prop', 'selectionEnd', '31'.length); }); }); describe('-', () => { beforeEach(() => { cy.visit(`/${DemoPath.Date}/API?separator=-`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it(' "-" => 31-12-2019', () => { cy.get('@input') .type('3112') .should('have.value', '31-12') .should('have.prop', 'selectionStart', '31-12'.length) .should('have.prop', 'selectionEnd', '31-12'.length) .type('2019') .should('have.value', '31-12-2019') .should('have.prop', 'selectionStart', '31-12-2019'.length) .should('have.prop', 'selectionEnd', '31-12-2019'.length); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/date-range/date-range-basic.cy.ts ================================================ import {DemoPath} from '@demo/constants'; import {BROWSER_SUPPORTS_REAL_EVENTS} from '../../../support/constants'; import {repeatKey} from '../../utils'; describe('DateRange | Basic', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateRange}/API?mode=dd%2Fmm%2Fyyyy`); cy.get('#demo-content input').should('be.visible').first().focus().as('input'); }); describe('basic typing', () => { const tests = [ // [Typed value, Masked value] ['1', '1'], ['18', '18'], ['181', '18.1'], ['1811', '18.11'], ['18112', '18.11.2'], ['18112016', '18.11.2016'], ['181120162', '18.11.2016 – 2'], ['1811201624', '18.11.2016 – 24'], ['18112016240', '18.11.2016 – 24.0'], ['181120162403', '18.11.2016 – 24.03'], ['18112016240320', '18.11.2016 – 24.03.20'], ['1811201624032020', '18.11.2016 – 24.03.2020'], ] as const; tests.forEach(([typedValue, maskedValue]) => { it(`Type "${typedValue}" => "${maskedValue}"`, () => { cy.get('@input') .type(typedValue) .should('have.value', maskedValue) .should('have.prop', 'selectionStart', maskedValue.length) .should('have.prop', 'selectionEnd', maskedValue.length); }); }); }); describe('invalid dates cases', () => { it('Empty input => Type "9" => "09|"', () => { cy.get('@input') .type('9') .should('have.value', '09') .should('have.prop', 'selectionStart', '09'.length) .should('have.prop', 'selectionEnd', '09'.length); }); it('12.12.2020| => Type "4" => 12.12.2020 - 04|', () => { cy.get('@input') .type('121220204') .should('have.value', '12.12.2020 – 04') .should('have.prop', 'selectionStart', '12.12.2020 – 04'.length) .should('have.prop', 'selectionEnd', '12.12.2020 – 04'.length); }); it('27| => type 2 => 27.02|', () => { cy.get('@input') .type('27') .should('have.value', '27') .should('have.prop', 'selectionStart', '27'.length) .should('have.prop', 'selectionEnd', '27'.length) .type('2') .should('have.value', '27.02') .should('have.prop', 'selectionStart', '27.02'.length) .should('have.prop', 'selectionEnd', '27.02'.length); }); it('12.12.2020 - 27| => type 3 => 12.12.2020 - 27.03|', () => { cy.get('@input') .type('1212202027') .should('have.value', '12.12.2020 – 27') .should('have.prop', 'selectionStart', '12.12.2020 – 27'.length) .should('have.prop', 'selectionEnd', '12.12.2020 – 27'.length) .type('3') .should('have.value', '12.12.2020 – 27.03') .should('have.prop', 'selectionStart', '12.12.2020 – 27.03'.length) .should('have.prop', 'selectionEnd', '12.12.2020 – 27.03'.length); }); it('3| => Type 7 => 03.07|', () => { cy.get('@input') .type('3') .should('have.value', '3') .should('have.prop', 'selectionStart', '3'.length) .should('have.prop', 'selectionEnd', '3'.length) .type('7') .should('have.value', '03.07') .should('have.prop', 'selectionStart', '03.07'.length) .should('have.prop', 'selectionEnd', '03.07'.length); }); it('12.12.2020 - 3| => Type 7 => 12.12.2020 - 03.07|', () => { cy.get('@input') .type('121220203') .should('have.value', '12.12.2020 – 3') .should('have.prop', 'selectionStart', '12.12.2020 – 3'.length) .should('have.prop', 'selectionEnd', '12.12.2020 – 3'.length) .type('7') .should('have.value', '12.12.2020 – 03.07') .should('have.prop', 'selectionStart', '12.12.2020 – 03.07'.length) .should('have.prop', 'selectionEnd', '12.12.2020 – 03.07'.length); }); }); describe('basic erasing (value = "20.01.1990 - 31.12.2022" & caret is placed after the last value)', () => { beforeEach(() => { cy.get('@input').type('2001199031122022'); }); const tests = [ // [How many times "Backspace"-key was pressed, caretPosition, Masked value] [1, '20.01.1990 – 31.12.202'.length, '20.01.1990 – 31.12.202'], [4, '20.01.1990 – 31.12'.length, '20.01.1990 – 31.12'], [5, '20.01.1990 – 31.1'.length, '20.01.1990 – 31.1'], [6, '20.01.1990 – 31'.length, '20.01.1990 – 31'], [8, '20.01.1990'.length, '20.01.1990'], [12, '20.01'.length, '20.01'], [13, '20.0'.length, '20.0'], [14, '20'.length, '20'], ] as const; tests.forEach(([n, caretIndex, maskedValue]) => { it(`Backspace x${n} => "${maskedValue}"`, () => { cy.get('@input') .type('{backspace}'.repeat(n)) .should('have.value', maskedValue) .should('have.prop', 'selectionStart', caretIndex) .should('have.prop', 'selectionEnd', caretIndex); }); }); it('Delete => no value change && no caret index change', () => { cy.get('@input') .type('{del}') .should('have.value', '20.01.1990 – 31.12.2022') .should('have.prop', 'selectionStart', '20.01.1990 – 31.12.2022'.length) .should('have.prop', 'selectionEnd', '20.01.1990 – 31.12.2022'.length); }); it('Type `deleteWordBackward` of `InputEvent` works', () => { cy.get('@input') .type('{ctrl+backspace}') .should('have.value', '20.01.1990') .should('have.prop', 'selectionStart', '20.01.1990'.length) .should('have.prop', 'selectionEnd', '20.01.1990'.length) .type('{ctrl+backspace}') .should('have.value', '') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('Type `deleteSoftLineBackward` of `InputEvent` works', () => { cy.get('@input') .trigger('beforeinput', {inputType: 'deleteSoftLineBackward'}) .trigger('input', {inputType: 'deleteSoftLineBackward'}) .should('have.value', '') .should('have.prop', 'selectionStart', ''.length) .should('have.prop', 'selectionEnd', ''.length); }); it('Type `deleteSoftLineForward` of `InputEvent` works', () => { cy.get('@input') .type('{moveToStart}') .trigger('beforeinput', {inputType: 'deleteSoftLineForward'}) .trigger('input', {inputType: 'deleteSoftLineForward'}) .should('have.value', '') .should('have.prop', 'selectionStart', ''.length) .should('have.prop', 'selectionEnd', ''.length); }); it('Type `deleteSoftLineBackward` in the middle works', () => { cy.get('@input') .type('{leftArrow}'.repeat(' 31.12.2022'.length)) .trigger('beforeinput', {inputType: 'deleteSoftLineBackward'}) .trigger('input', {inputType: 'deleteSoftLineBackward'}) .should('have.value', '01.01.0001 – 31.12.2022') .should('have.prop', 'selectionStart', ''.length) .should('have.prop', 'selectionEnd', ''.length); }); it('Type `deleteSoftLineForward` in the middle works', () => { cy.get('@input') .type('{leftArrow}'.repeat(' 31.12.2022'.length)) .trigger('beforeinput', {inputType: 'deleteSoftLineForward'}) .trigger('input', {inputType: 'deleteSoftLineForward'}) .should('have.value', '20.01.1990') .should('have.prop', 'selectionStart', '20.01.1990'.length) .should('have.prop', 'selectionEnd', '20.01.1990'.length); }); }); describe('Editing somewhere in the middle of a value (NOT the last character)', () => { it('25.02.19|99 - 17.05.2000 => Backspace => 25.02.1|099 - 17.05.2000 => Type "8" => 25.02.18|99 - 17.05.2000', () => { cy.get('@input') .type('25.02.1999-17.05.2000') .should('have.value', '25.02.1999 – 17.05.2000') .type('{leftArrow}'.repeat('99 – 17.05.2000'.length)) .should('have.prop', 'selectionStart', '25.02.19'.length) .should('have.prop', 'selectionEnd', '25.02.19'.length) .type('{backspace}') .should('have.value', '25.02.1099 – 17.05.2000') .should('have.prop', 'selectionStart', '25.02.1'.length) .should('have.prop', 'selectionEnd', '25.02.1'.length) .type('8') .should('have.value', '25.02.1899 – 17.05.2000') .should('have.prop', 'selectionStart', '25.02.18'.length) .should('have.prop', 'selectionEnd', '25.02.18'.length); }); it('13.06.1736 - 14.09|.1821 => Backspace => 13.06.1736 - 14.0|1.1821 => Type "3" => 13.06.1736 - 14.03|.1821', () => { cy.get('@input') .type('13.06.1736-14.09.1821') .should('have.value', '13.06.1736 – 14.09.1821') .type('{leftArrow}'.repeat('.1821'.length)) .should('have.prop', 'selectionStart', '13.06.1736 - 14.09'.length) .should('have.prop', 'selectionEnd', '13.06.1736 - 14.09'.length) .type('{backspace}') .should('have.value', '13.06.1736 – 14.01.1821') .should('have.prop', 'selectionStart', '13.06.1736 - 14.0'.length) .should('have.prop', 'selectionEnd', '13.06.1736 - 14.0'.length) .type('3') .should('have.value', '13.06.1736 – 14.03.1821') .should('have.prop', 'selectionStart', '13.06.1736 - 14.03.'.length) .should('have.prop', 'selectionEnd', '13.06.1736 - 14.03.'.length); }); it('12|.01.2008 - 27.01.2020 => Backspace => 1|0.01.2008 - 27.01.2020 => Type "1" => 11|.01.2008 - 27.01.2020', () => { cy.get('@input') .type('12012008-27012020') .should('have.value', '12.01.2008 – 27.01.2020') .type('{leftArrow}'.repeat('.01.2008 - 27.01.2020'.length)) .should('have.prop', 'selectionStart', '12'.length) .should('have.prop', 'selectionEnd', '12'.length) .type('{backspace}') .should('have.value', '10.01.2008 – 27.01.2020') .should('have.prop', 'selectionStart', '1'.length) .should('have.prop', 'selectionEnd', '1'.length) .type('1') .should('have.value', '11.01.2008 – 27.01.2020') .should('have.prop', 'selectionStart', '11.'.length) .should('have.prop', 'selectionEnd', '11.'.length); }); it('12.01.2008 - 2|7.01.2020 => Backspace => 12.01.2008 - |07.01.2020 => Type "1" => 12.01.2008 - 1|7.01.2020', () => { cy.get('@input') .type('12012008-27012020') .should('have.value', '12.01.2008 – 27.01.2020') .type('{leftArrow}'.repeat('7.01.2020'.length)) .should('have.prop', 'selectionStart', '12.01.2008 - 2'.length) .should('have.prop', 'selectionEnd', '12.01.2008 - 2'.length) .type('{backspace}') .should('have.value', '12.01.2008 – 07.01.2020') .should('have.prop', 'selectionStart', '12.01.2008 - '.length) .should('have.prop', 'selectionEnd', '12.01.2008 - '.length) .type('1') .should('have.value', '12.01.2008 – 17.01.2020') .should('have.prop', 'selectionStart', '12.01.2008 - 1'.length) .should('have.prop', 'selectionEnd', '12.01.2008 - 1'.length); }); it('12.|12.2010 - 12.12.2020 => Type "9" => 12.09.|2010 - 12.12.2020', () => { cy.get('@input') .type('12122010-12122020') .should('have.value', '12.12.2010 – 12.12.2020') .type('{leftArrow}'.repeat('12.2010 - 12.12.2020'.length)) .should('have.prop', 'selectionStart', '12.'.length) .should('have.prop', 'selectionEnd', '12.'.length) .type('9') .should('have.value', '12.09.2010 – 12.12.2020') .should('have.prop', 'selectionStart', '12.09.'.length) .should('have.prop', 'selectionEnd', '12.09.'.length); }); it('12.12.2010 - 12.|12.2020 => Type "9" => 12.12.2010 - 12.09|.2020', () => { cy.get('@input') .type('12122010-12122020') .should('have.value', '12.12.2010 – 12.12.2020') .type('{leftArrow}'.repeat('12.2020'.length)) .should('have.prop', 'selectionStart', '12.12.2010 - 12.'.length) .should('have.prop', 'selectionEnd', '12.12.2010 - 12.'.length) .type('9') .should('have.value', '12.12.2010 – 12.09.2020') .should('have.prop', 'selectionStart', '12.12.2010 - 12.09.'.length) .should('have.prop', 'selectionEnd', '12.12.2010 - 12.09.'.length); }); it('|15.01.2012 - 15.01.2022 => Type "3" => 3|0.01.2012 - 15.01.2022', () => { cy.get('@input') .type('15012012-15012022') .should('have.value', '15.01.2012 – 15.01.2022') .type('{leftArrow}'.repeat('15.01.2012 - 15.01.2022'.length)) .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0) .type('3') .should('have.value', '30.01.2012 – 15.01.2022') .should('have.prop', 'selectionStart', '3'.length) .should('have.prop', 'selectionEnd', '3'.length); }); it('15.01.2012 - |15.01.2022 => Type "3" => 15.01.2012 - 3|0.01.2022', () => { cy.get('@input') .type('15012012-15012022') .should('have.value', '15.01.2012 – 15.01.2022') .type('{leftArrow}'.repeat('15.01.2022'.length)) .should('have.prop', 'selectionStart', '15.01.2012 - '.length) .should('have.prop', 'selectionEnd', '15.01.2012 - '.length) .type('3') .should('have.value', '15.01.2012 – 30.01.2022') .should('have.prop', 'selectionStart', '15.01.2012 - 3'.length) .should('have.prop', 'selectionEnd', '15.01.2012 - 3'.length); }); }); describe('Fixed values', () => { it('Press Backspace after fixed value => no value change => move caret to the left', () => { cy.get('@input') .type('28032015-01042021') .should('have.value', '28.03.2015 – 01.04.2021') .type('{leftArrow}'.repeat(' 01.04.2021'.length)) .should('have.prop', 'selectionStart', '28.03.2015 -'.length) .should('have.prop', 'selectionEnd', '28.03.2015 -'.length) .type('{backspace}') .should('have.value', '28.03.2015 – 01.04.2021') .should('have.prop', 'selectionStart', '28.03.2015 '.length) .should('have.prop', 'selectionEnd', '28.03.2015 '.length); }); it('Press Delete after fixed value => no value change => move caret to the right', () => { cy.get('@input') .type('28032015-01042021') .should('have.value', '28.03.2015 – 01.04.2021') .type('{leftArrow}'.repeat('.04.2021'.length)) .should('have.prop', 'selectionStart', '28.03.2015 – 01'.length) .should('have.prop', 'selectionEnd', '28.03.2015 – 01'.length) .type('{del}') .should('have.value', '28.03.2015 – 01.04.2021') .should('have.prop', 'selectionStart', '28.03.2015 – 01.'.length) .should('have.prop', 'selectionEnd', '28.03.2015 – 01.'.length); }); }); describe('Text selection', () => { describe('Select range and press Backspace / Delete', () => { it( '10.|12|.2005 - 16.12.2007 => Backspace => 10.|01.2005 - 16.12.2007', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('10122005-16122007') .should('have.value', '10.12.2005 – 16.12.2007') .type('{leftArrow}'.repeat('.2005 - 16.12.2007'.length)) .realPress([ 'Shift', ...repeatKey('ArrowLeft', '12'.length), 'Backspace', ]); cy.get('@input') .should('have.value', '10.01.2005 – 16.12.2007') .should('have.prop', 'selectionStart', '10.'.length) .should('have.prop', 'selectionEnd', '10.'.length); }, ); it( '10.12.2005 - |16|.12.2007 => Backspace => 10.12.2005 - |01.12.2007', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('10122005-16122007') .should('have.value', '10.12.2005 – 16.12.2007') .type('{leftArrow}'.repeat('.12.2007'.length)) .realPress([ 'Shift', ...repeatKey('ArrowLeft', '16'.length), 'Backspace', ]); cy.get('@input') .should('have.value', '10.12.2005 – 01.12.2007') .should('have.prop', 'selectionStart', '10.12.2005 - '.length) .should('have.prop', 'selectionEnd', '10.12.2005 - '.length); }, ); it( '1|1.1|1.2011 - 11.11.2025 => Delete => 10.0|1.2011 - 11.11.2025', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('11112011-11112025') .should('have.value', '11.11.2011 – 11.11.2025') .type('{leftArrow}'.repeat('1.2011 – 11.11.2025'.length)) .realPress(['Shift', ...repeatKey('ArrowLeft', '1.1'.length)]); cy.get('@input') .type('{del}') .should('have.value', '10.01.2011 – 11.11.2025') .should('have.prop', 'selectionStart', '10.0'.length) .should('have.prop', 'selectionEnd', '10.0'.length); }, ); it( '11.11.2011 - 1|1.1|1.2025 => Delete => 11.11.2011 - 10.0|1.2025', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('11112011-11112025') .should('have.value', '11.11.2011 – 11.11.2025') .type('{leftArrow}'.repeat('1.2025'.length)) .realPress(['Shift', ...repeatKey('ArrowLeft', '1.1'.length)]); cy.get('@input') .type('{del}') .should('have.value', '11.11.2011 – 10.01.2025') .should('have.prop', 'selectionStart', '11.11.2011 - 10.0'.length) .should('have.prop', 'selectionEnd', '11.11.2011 - 10.0'.length); }, ); }); describe('Select range and press new digit', () => { it( '|12|.11.2022 (specifically do not completes value) => Press 3 => 3|0.11.2022', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('12112022') .type('{leftArrow}'.repeat('.11.2022'.length)) .realPress(['Shift', ...repeatKey('ArrowLeft', '12'.length)]); cy.get('@input') .type('3') .should('have.value', '30.11.2022') .should('have.prop', 'selectionStart', '3'.length) .should('have.prop', 'selectionEnd', '3'.length); }, ); it( '01.01.2000 - |12|.11.2022 => Press 3 => 01.01.2000 - 3|0.11.2022', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('01012000-12112022') .type('{leftArrow}'.repeat('.11.2022'.length)) .realPress(['Shift', ...repeatKey('ArrowLeft', '12'.length)]); cy.get('@input') .type('3') .should('have.value', '01.01.2000 – 30.11.2022') .should('have.prop', 'selectionStart', '01.01.2000 - 3'.length) .should('have.prop', 'selectionEnd', '01.01.2000 - 3'.length); }, ); }); }); describe('The 2nd date is less than the 1st one', () => { it('If caret is at the end, swap dates: 31.12.2023 – 01.01.202| => Type 0 => 01.01.2020 – 31.12.2023|', () => { cy.get('@input') .type('31.12.2023-01.01.202') .should('have.value', '31.12.2023 – 01.01.202') .should('have.prop', 'selectionStart', '31.12.2023 – 01.01.202'.length) .should('have.prop', 'selectionEnd', '31.12.2023 – 01.01.202'.length) .type('0') .should('have.value', '01.01.2020 – 31.12.2023') .should('have.prop', 'selectionStart', '01.01.2020 – 31.12.2023'.length) .should('have.prop', 'selectionEnd', '01.01.2020 – 31.12.2023'.length); }); it('If caret is NOT at the end, do NOT swap dates: 11.11.201|1 – 12.12.2012 => Type 5 => 11.11.2015 – |12.12.2012', () => { cy.get('@input') .type('11112011-12122012') .should('have.value', '11.11.2011 – 12.12.2012') .should('have.prop', 'selectionStart', '11.11.2011 – 12.12.2012'.length) .should('have.prop', 'selectionEnd', '11.11.2011 – 12.12.2012'.length) .type('{leftArrow}'.repeat('1 – 12.12.2012'.length)) .type('5') .should('have.value', '11.11.2015 – 12.12.2012') .should('have.prop', 'selectionStart', '11.11.2015 – '.length) .should('have.prop', 'selectionEnd', '11.11.2015 – '.length); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/date-range/date-range-custom-range-separator.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('DateRange | CustomRangeSeparator', () => { describe(' ~ ', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateRange}/API?rangeSeparator=~`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('14.12.1997~09.07.2015', () => { cy.get('@input') .type('14121997972015') .should('have.value', '14.12.1997~09.07.2015'); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/date-range/date-range-fullwidth-to-halfwidth.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('DateRange | Full width character parsing', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateRange}/API?mode=yyyy%2Fmm%2Fdd&dateSeparator=%2F`); cy.get('#demo-content input').should('be.visible').first().focus().as('input'); }); describe('basic typing', () => { const tests = [ // [Typed value, Masked value] ['2', '2'], ['20', '20'], ['201', '201'], ['2016', '2016'], ['20162', '2016/02'], ['2016228', '2016/02/28'], ['20162282', '2016/02/28 – 2'], ['201622820', '2016/02/28 – 20'], ['20162282020', '2016/02/28 – 2020'], ['201622820204', '2016/02/28 – 2020/04'], ['2016228202044', '2016/02/28 – 2020/04/04'], ] as const; tests.forEach(([typedValue, maskedValue]) => { it(`Type "${typedValue}" => "${maskedValue}"`, () => { cy.get('@input') .type(typedValue) .should('have.value', maskedValue) .should('have.prop', 'selectionStart', maskedValue.length) .should('have.prop', 'selectionEnd', maskedValue.length); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/date-range/date-range-min-max-length.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('DateRange | Min & Max Length', () => { describe('[minLength]="3"', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateRange}/API?minLength$=0`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('06.02.2023 - 06.02.202| => Type 3 => 06.02.2023 - 08.02.2023', () => { cy.get('@input') .type('06.02.2023 - 06.02.202') .should('have.value', '06.02.2023 – 06.02.202') .should('have.prop', 'selectionStart', '06.02.2023 – 06.02.202'.length) .should('have.prop', 'selectionEnd', '06.02.2023 – 06.02.202'.length) .type('3') .should('have.value', '06.02.2023 – 08.02.2023') .should('have.prop', 'selectionStart', '06.02.2023 – 08.02.2023'.length) .should('have.prop', 'selectionEnd', '06.02.2023 – 08.02.2023'.length); }); it('06.02.2023 - 07.02.202| => Type 3 => 06.02.2023 - 08.02.2023', () => { cy.get('@input') .type('06.02.2023 - 07.02.202') .should('have.value', '06.02.2023 – 07.02.202') .should('have.prop', 'selectionStart', '06.02.2023 – 07.02.202'.length) .should('have.prop', 'selectionEnd', '06.02.2023 – 07.02.202'.length) .type('3') .should('have.value', '06.02.2023 – 08.02.2023') .should('have.prop', 'selectionStart', '06.02.2023 – 08.02.2023'.length) .should('have.prop', 'selectionEnd', '06.02.2023 – 08.02.2023'.length); }); it('06.02.2023 - 08.02.2023 => valid input, no changes', () => { cy.get('@input') .type('06.02.2023 - 08.02.2023') .should('have.value', '06.02.2023 – 08.02.2023') .should('have.prop', 'selectionStart', '06.02.2023 – 08.02.2023'.length) .should('have.prop', 'selectionEnd', '06.02.2023 – 08.02.2023'.length); }); it('06.02.2023 - 09.02.2023 => valid input, no changes', () => { cy.get('@input') .type('06.02.2023 - 09.02.2023') .should('have.value', '06.02.2023 – 09.02.2023') .should('have.prop', 'selectionStart', '06.02.2023 – 09.02.2023'.length) .should('have.prop', 'selectionEnd', '06.02.2023 – 09.02.2023'.length); }); }); describe('[maxLength]="5"', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateRange}/API?maxLength$=0`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('06.02.2023 - 09.02.2023 => valid input, no changes', () => { cy.get('@input') .type('06.02.2023 - 09.02.2023') .should('have.value', '06.02.2023 – 09.02.2023') .should('have.prop', 'selectionStart', '06.02.2023 – 09.02.2023'.length) .should('have.prop', 'selectionEnd', '06.02.2023 – 09.02.2023'.length); }); it('06.02.2023 - 10.02.2023 => valid input, no changes', () => { cy.get('@input') .type('06.02.2023-10.02.2023') .should('have.value', '06.02.2023 – 10.02.2023') .should('have.prop', 'selectionStart', '06.02.2023 – 10.02.2023'.length) .should('have.prop', 'selectionEnd', '06.02.2023 – 10.02.2023'.length); }); it('06.02.2023 - 11.02.202| => Type 3 => 06.02.2023 - 10.02.2023', () => { cy.get('@input') .type('06.02.2023 - 11.02.202') .should('have.value', '06.02.2023 – 11.02.202') .should('have.prop', 'selectionStart', '06.02.2023 – 11.02.202'.length) .should('have.prop', 'selectionEnd', '06.02.2023 – 11.02.202'.length) .type('3') .should('have.value', '06.02.2023 – 10.02.2023') .should('have.prop', 'selectionStart', '06.02.2023 – 10.02.2023'.length) .should('have.prop', 'selectionEnd', '06.02.2023 – 10.02.2023'.length); }); it('06.02.2023 - 10.03.202| => Type 3 => 06.02.2023 - 10.02.2023', () => { cy.get('@input') .type('06.02.2023 - 10.03.202') .should('have.value', '06.02.2023 – 10.03.202') .should('have.prop', 'selectionStart', '06.02.2023 – 10.03.202'.length) .should('have.prop', 'selectionEnd', '06.02.2023 – 10.03.202'.length) .type('3') .should('have.value', '06.02.2023 – 10.02.2023') .should('have.prop', 'selectionStart', '06.02.2023 – 10.02.2023'.length) .should('have.prop', 'selectionEnd', '06.02.2023 – 10.02.2023'.length); }); }); describe('[minLength]="3" & [maxLength]="5"', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateRange}/API?maxLength$=0&minLength$=0`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('01.01.2023 - 02.01.202| => Type 3 => 01.01.2023 - 03.01.2023', () => { cy.get('@input') .type('01.01.2023 - 02.01.202') .should('have.value', '01.01.2023 – 02.01.202') .should('have.prop', 'selectionStart', '01.01.2023 – 02.01.202'.length) .should('have.prop', 'selectionEnd', '01.01.2023 – 02.01.202'.length) .type('3') .should('have.value', '01.01.2023 – 03.01.2023') .should('have.prop', 'selectionStart', '01.01.2023 – 03.01.2023'.length) .should('have.prop', 'selectionEnd', '01.01.2023 – 03.01.2023'.length); }); it('01.01.2023 - 03.01.2023 => valid input, no changes', () => { cy.get('@input') .type('01.01.2023 - 03.01.2023') .should('have.value', '01.01.2023 – 03.01.2023') .should('have.prop', 'selectionStart', '01.01.2023 – 03.01.2023'.length) .should('have.prop', 'selectionEnd', '01.01.2023 – 03.01.2023'.length); }); it('01.01.2023 - 05.01.2023 => valid input, no changes', () => { cy.get('@input') .type('01.01.2023 - 05.01.2023') .should('have.value', '01.01.2023 – 05.01.2023') .should('have.prop', 'selectionStart', '01.01.2023 – 05.01.2023'.length) .should('have.prop', 'selectionEnd', '01.01.2023 – 05.01.2023'.length); }); it('01.01.2023 - 06.01.202| => Type 3 => 01.01.2023 - 05.01.2023', () => { cy.get('@input') .type('01.01.2023 - 06.01.202') .should('have.value', '01.01.2023 – 06.01.202') .should('have.prop', 'selectionStart', '01.01.2023 – 06.01.202'.length) .should('have.prop', 'selectionEnd', '01.01.2023 – 06.01.202'.length) .type('3') .should('have.value', '01.01.2023 – 05.01.2023') .should('have.prop', 'selectionStart', '01.01.2023 – 05.01.2023'.length) .should('have.prop', 'selectionEnd', '01.01.2023 – 05.01.2023'.length); }); }); describe('[minLength]={month: 1}', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateRange}/API?minLength$=2`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('01.03.2018 - 31.03.2018 => valid input', () => { cy.get('@input') .type('01.03.2018 - 31.03.2018') .should('have.value', '01.03.2018 – 31.03.2018') .should('have.prop', 'selectionStart', '01.03.2018 - 31.03.2018'.length) .should('have.prop', 'selectionEnd', '01.03.2018 - 31.03.2018'.length); }); it('02.03.2018 - 31.03.201| => Type 8 => 02.03.2018 - 01.04.2018', () => { cy.get('@input') .type('02.03.2018 - 31.03.201') .should('have.value', '02.03.2018 – 31.03.201') .should('have.prop', 'selectionStart', '02.03.2018 - 31.03.201'.length) .should('have.prop', 'selectionEnd', '02.03.2018 - 31.03.201'.length) .type('8') .should('have.value', '02.03.2018 – 01.04.2018') .should('have.prop', 'selectionStart', '02.03.2018 - 01.04.2018'.length) .should('have.prop', 'selectionEnd', '02.03.2018 - 01.04.2018'.length); }); it('18.02.2018 - 17.03.2018 => valid input', () => { cy.get('@input') .type('18.02.2018 - 17.03.2018') .should('have.value', '18.02.2018 – 17.03.2018') .should('have.prop', 'selectionStart', '18.02.2018 - 17.03.2018'.length) .should('have.prop', 'selectionEnd', '18.02.2018 - 17.03.2018'.length); }); it('19.02.2018 - 17.03.201| => Type 8 => 19.02.2018 - 18.03.2018', () => { cy.get('@input') .type('19.02.2018 - 17.03.201') .should('have.value', '19.02.2018 – 17.03.201') .should('have.prop', 'selectionStart', '19.02.2018 - 17.03.201'.length) .should('have.prop', 'selectionEnd', '19.02.2018 - 17.03.201'.length) .type('8') .should('have.value', '19.02.2018 – 18.03.2018') .should('have.prop', 'selectionStart', '19.02.2018 - 18.03.2018'.length) .should('have.prop', 'selectionEnd', '19.02.2018 - 18.03.2018'.length); }); }); describe('[minLength]={month: 1, day: 1}', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateRange}/API?minLength$=3`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('28.02.2018 - 31.03.2018 => valid input', () => { cy.get('@input') .type('28.02.2018 - 31.03.2018') .should('have.value', '28.02.2018 – 31.03.2018') .should('have.prop', 'selectionStart', '28.02.2018 - 31.03.2018'.length) .should('have.prop', 'selectionEnd', '28.02.2018 - 31.03.2018'.length); }); it('01.03.2018 - 31.03.201| => Type 8 => 01.03.2018 - 01.04.2018', () => { cy.get('@input') .type('01.03.2018 - 31.03.201') .should('have.value', '01.03.2018 – 31.03.201') .should('have.prop', 'selectionStart', '01.03.2018 - 31.03.201'.length) .should('have.prop', 'selectionEnd', '01.03.2018 - 31.03.201'.length) .type('8') .should('have.value', '01.03.2018 – 01.04.2018') .should('have.prop', 'selectionStart', '01.03.2018 - 01.04.2018'.length) .should('have.prop', 'selectionEnd', '01.03.2018 - 01.04.2018'.length); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/date-range/date-range-min-max.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('DateRange | Min & Max dates', () => { describe('Max', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateRange}/API?max=2020-05-05`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('accepts date less than max value', () => { cy.get('@input') .type('18.12.2019-14.01.2020') .should('have.value', '18.12.2019 – 14.01.2020') .should('have.prop', 'selectionStart', '18.12.2019 – 14.01.2020'.length) .should('have.prop', 'selectionEnd', '18.12.2019 – 14.01.2020'.length); }); it('18.12.2019 - 14.01.202| => Type 5 => 18.12.2019-05.05.2020 (max value)', () => { cy.get('@input') .type('18.12.2019-14.01.202') .should('have.value', '18.12.2019 – 14.01.202') .should('have.prop', 'selectionStart', '18.12.2019 – 14.01.202'.length) .should('have.prop', 'selectionEnd', '18.12.2019 – 14.01.202'.length) .type('5') .should('have.value', '18.12.2019 – 05.05.2020') .should('have.prop', 'selectionStart', '18.12.2019 – 05.05.2020'.length) .should('have.prop', 'selectionEnd', '18.12.2019 – 05.05.2020'.length); }); it('18.12.2019 - 0|3.05.2020 => Type 7 => 18.12.2019 - 05|.05.2020 (max value)', () => { cy.get('@input') .type('18.12.2019-03.05.2020') .type('{leftArrow}'.repeat('3.05.2020'.length)) .type('7') .should('have.value', '18.12.2019 – 05.05.2020') .should('have.prop', 'selectionStart', '18.12.2019 – 05.'.length) .should('have.prop', 'selectionEnd', '18.12.2019 – 05.'.length); }); it('18.12.2019 - 03.0|4.2020 => Type 7 => 18.12.2019 - 05.05|.2020 (max value)', () => { cy.get('@input') .type('18.12.2019-03.04.2020') .type('{leftArrow}'.repeat('4.2020'.length)) .type('7') .should('have.value', '18.12.2019 – 05.05.2020') .should('have.prop', 'selectionStart', '18.12.2019 – 05.05.'.length) .should('have.prop', 'selectionEnd', '18.12.2019 – 05.05.'.length); }); }); describe('Min', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateRange}/API?min=1995-10-14`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('accepts date more than min value', () => { cy.get('@input') .type('13.04.2001-08.03.2002') .should('have.value', '13.04.2001 – 08.03.2002') .should('have.prop', 'selectionStart', '13.04.2001 – 08.03.2002'.length) .should('have.prop', 'selectionEnd', '13.04.2001 – 08.03.2002'.length); }); it('15.11.1997 - 15.12.199| => Type 3 => (min date validation + dates swap) => 14.10.1995 - 15.11.1997', () => { cy.get('@input') .type('15.11.1997-15.12.199') .should('have.value', '15.11.1997 – 15.12.199') .should('have.prop', 'selectionStart', '15.11.1997 – 15.12.199'.length) .should('have.prop', 'selectionEnd', '15.11.1997 – 15.12.199'.length) .type('3') .should('have.value', '14.10.1995 – 15.11.1997') .should('have.prop', 'selectionStart', '14.10.1995 – 15.11.1997'.length) .should('have.prop', 'selectionEnd', '14.10.1995 – 15.11.1997'.length); }); it('15.10.1995 - 1|7.10.1995 => Type 2 => 15.10.1995 - 14.|10.1995 (min)', () => { cy.get('@input') .type('15.10.1995-17.10.1995') .type('{leftArrow}'.repeat('7.10.1995'.length)) .type('2') .should('have.value', '15.10.1995 – 14.10.1995') .should('have.prop', 'selectionStart', '15.10.1995 – 14.'.length) .should('have.prop', 'selectionEnd', '15.10.1995 – 14.'.length); }); it('15.10.1995 - 17.|10.1995 => Type 9 => 15.10.1995 - 14.10|.1995 (min)', () => { cy.get('@input') .type('15.10.1995-17.10.1995') .type('{leftArrow}'.repeat('10.1995'.length)) .type('9') .should('have.value', '15.10.1995 – 14.10.1995') .should('have.prop', 'selectionStart', '15.10.1995 – 14.10.'.length) .should('have.prop', 'selectionEnd', '15.10.1995 – 14.10.'.length); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/date-range/date-range-mode.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('DateRange | Mode', () => { describe('mm.dd.yyyy', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateRange}/API?mode=mm%2Fdd%2Fyyyy`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('12.31.2000 - 11.30.2019', () => { cy.get('@input') .type('1231200011302019') .should('have.value', '12.31.2000 – 11.30.2019') .should('have.prop', 'selectionStart', '12.31.2000 - 11.30.2019'.length) .should('have.prop', 'selectionEnd', '12.31.2000 - 11.30.2019'.length); }); it('Empty input => Type 3 => 03|', () => { cy.get('@input') .type('3') .should('have.value', '03') .should('have.prop', 'selectionStart', '03'.length) .should('have.prop', 'selectionEnd', '03'.length); }); it('12| => Type 3 => 12.3|', () => { cy.get('@input') .type('123') .should('have.value', '12.3') .should('have.prop', 'selectionStart', '12.3'.length) .should('have.prop', 'selectionEnd', '12.3'.length); }); it('12| => Type 4 => 12.04|', () => { cy.get('@input') .type('124') .should('have.value', '12.04') .should('have.prop', 'selectionStart', '12.04'.length) .should('have.prop', 'selectionEnd', '12.04'.length); }); }); describe('yyyy.mm.dd', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateRange}/API?mode=yyyy%2Fmm%2Fdd`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('2000.12.31 - 2019.11.30', () => { cy.get('@input') .type('2000123120191130') .should('have.value', '2000.12.31 – 2019.11.30') .should('have.prop', 'selectionStart', '2000.12.31 - 2019.11.30'.length) .should('have.prop', 'selectionEnd', '2000.12.31 - 2019.11.30'.length); }); it('2000| => Type 3 => 2000.03|', () => { cy.get('@input') .type('20003') .should('have.value', '2000.03') .should('have.prop', 'selectionStart', '2000.03'.length) .should('have.prop', 'selectionEnd', '2000.03'.length); }); it('2000.03| => Type 5 => 2000.03.05|', () => { cy.get('@input') .type('200035') .should('have.value', '2000.03.05') .should('have.prop', 'selectionStart', '2000.03.05'.length) .should('have.prop', 'selectionEnd', '2000.03.05'.length); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/date-range/date-range-segments-zero-padding.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('DateRange | Date segments zero padding (pads digits with zero if date segment exceeds its max possible value)', () => { describe('[mode]="dd.mm.yyyy"', () => { const mode = encodeURIComponent('dd/mm/yyyy'); const FIRST_DATE = '01.01.2000'; beforeEach(() => { cy.visit( `/${DemoPath.DateRange}/API?mode=${mode}&dateSeparator=.&rangeSeparator=-`, ); cy.get('#demo-content input') .should('be.visible') .first() .focus() .type('01012000') .should('have.value', FIRST_DATE) .as('input'); }); describe('if users enters two digits and its combination exceeds the first (and only first!) non-year date segment - pad the first digit with zero', () => { it('{firstDate} => Type 35 => {firstDate}-03.05|', () => { cy.get('@input') .type('35') .should('have.value', `${FIRST_DATE}-03.05`) .should('have.prop', 'selectionStart', `${FIRST_DATE}-03.05`.length) .should('have.prop', 'selectionEnd', `${FIRST_DATE}-03.05`.length); }); it('{firstDate}-|19.01.2025 => Type 3 => {firstDate}-3|0.01.2025', () => { cy.get('@input') .type('19012025') .should('have.value', `${FIRST_DATE}-19.01.2025`) .type('{leftArrow}'.repeat('19.01.2025'.length)) .should('have.prop', 'selectionStart', `${FIRST_DATE}-`.length) .should('have.prop', 'selectionEnd', `${FIRST_DATE}-`.length) .type('3') .should('have.value', `${FIRST_DATE}-30.01.2025`) .should('have.prop', 'selectionStart', `${FIRST_DATE}-3`.length) .should('have.prop', 'selectionEnd', `${FIRST_DATE}-3`.length); }); it('{firstDate}-31.1| => Type 3 => {firstDate}-31.1|', () => { cy.get('@input') .type('311') .should('have.value', `${FIRST_DATE}-31.1`) .type('3') .should('have.value', `${FIRST_DATE}-31.1`) .should('have.prop', 'selectionStart', `${FIRST_DATE}-31.1`.length) .should('have.prop', 'selectionEnd', `${FIRST_DATE}-31.1`.length); }); }); }); describe('[mode]="mm/dd/yyyy"', () => { const mode = encodeURIComponent('mm/dd/yyyy'); const dateSeparator = encodeURIComponent('/'); const FIRST_DATE = '01/01/2000'; beforeEach(() => { cy.visit( `/${DemoPath.DateRange}/API?mode=${mode}&dateSeparator=${dateSeparator}&rangeSeparator=-`, ); cy.get('#demo-content input') .should('be.visible') .first() .focus() .type('01012000') .should('have.value', FIRST_DATE) .as('input'); }); describe('handles month value exceeding maximum', () => { it('{firstDate} => Type 13 => {firstDate}-01/3', () => { cy.get('@input') .type('13') .should('have.value', `${FIRST_DATE}-01/3`) .should('have.prop', 'selectionStart', `${FIRST_DATE}-01/3`.length) .should('have.prop', 'selectionEnd', `${FIRST_DATE}-01/3`.length); }); }); }); describe('[mode]="yyyy/mm/dd"', () => { const mode = encodeURIComponent('yyyy/mm/dd'); const dateSeparator = encodeURIComponent('/'); const FIRST_DATE = '2000/01/01'; beforeEach(() => { cy.visit( `/${DemoPath.DateRange}/API?mode=${mode}&dateSeparator=${dateSeparator}&&rangeSeparator=-`, ); cy.get('#demo-content input') .should('be.visible') .first() .focus() .type('20000101') .should('have.value', FIRST_DATE) .as('input'); }); describe('if users enters two digits and its combination exceeds the first (and only first!) non-year date segment - pad the first digit with zero', () => { it('2025 => Type 35 => 2025/03/05|', () => { cy.get('@input') .type('202535') .should('have.value', `${FIRST_DATE}-2025/03/05`) .should( 'have.prop', 'selectionStart', `${FIRST_DATE}-2025/03/05`.length, ) .should( 'have.prop', 'selectionEnd', `${FIRST_DATE}-2025/03/05`.length, ); }); it('2025/|09/30 => Type 1 => 2025/1|0/30', () => { cy.get('@input') .type('2025930') .should('have.value', `${FIRST_DATE}-2025/09/30`) .type('{leftArrow}'.repeat('09/30'.length)) .should('have.prop', 'selectionStart', `${FIRST_DATE}-2025/`.length) .should('have.prop', 'selectionEnd', `${FIRST_DATE}-2025/`.length) .type('1') .should('have.value', `${FIRST_DATE}-2025/10/30`) .should('have.prop', 'selectionStart', `${FIRST_DATE}-2025/1`.length) .should('have.prop', 'selectionEnd', `${FIRST_DATE}-2025/1`.length); }); it('2025.01.3| => Type 5 => 2025.01.3|', () => { cy.get('@input') .type('2025013') .should('have.value', `${FIRST_DATE}-2025/01/3`) .type('5') .should('have.value', `${FIRST_DATE}-2025/01/3`) .should( 'have.prop', 'selectionStart', `${FIRST_DATE}-2025/01/3`.length, ) .should( 'have.prop', 'selectionEnd', `${FIRST_DATE}-2025/01/3`.length, ); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/date-range/date-range-separator.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('DateRange | dateSeparator', () => { describe('/', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateRange}/API?dateSeparator=/`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('14/12/1997 – 09/07/2015', () => { cy.get('@input') .type('14121997972015') .should('have.value', '14/12/1997 – 09/07/2015'); }); it('rejects dot as separator', () => { cy.get('@input') .type('1412200011.') .should('have.value', '14/12/2000 – 11') .type('12.') .should('have.value', '14/12/2000 – 11/12'); }); }); describe('-', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateRange}/API?dateSeparator=-`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('14-12-1997 – 09-07-2015', () => { cy.get('@input') .type('14121997972015') .should('have.value', '14-12-1997 – 09-07-2015'); }); it('rejects dot as separator', () => { cy.get('@input') .type('14') .should('have.value', '14') .type('12.') .should('have.value', '14-12'); }); }); describe('dates separator', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateRange}/API?dateSeparator=/`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('14/12/1997 – ', () => { cy.get('@input') .type('14121997-') .should('have.value', '14/12/1997 – ') .should('have.prop', 'selectionStart', '14/12/1997 – '.length) .should('have.prop', 'selectionEnd', '14/12/1997 – '.length); }); it('14/12/19 => "-" => 14/12/19', () => { cy.get('@input') .type('141219-') .should('have.value', '14/12/19') .should('have.prop', 'selectionStart', '14/12/19'.length) .should('have.prop', 'selectionEnd', '14/12/19'.length); }); it('type date with all separators ', () => { cy.get('@input') .type('14/12/1997-14/12/1997') .should('have.value', '14/12/1997 – 14/12/1997') .should('have.prop', 'selectionStart', '14/12/1997 – 14/12/1997'.length) .should('have.prop', 'selectionEnd', '14/12/1997 – 14/12/1997'.length); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/date-time/date-time-basic.cy.ts ================================================ import {DemoPath} from '@demo/constants'; import {BROWSER_SUPPORTS_REAL_EVENTS} from '../../../support/constants'; import {repeatKey} from '../../utils'; describe('DateTime | Basic', () => { beforeEach(() => { cy.visit( `/${DemoPath.DateTime}/API?dateMode=dd%2Fmm%2Fyyyy&timeMode=HH:MM:SS.MSS`, ); cy.get('#demo-content input').should('be.visible').first().focus().as('input'); }); describe('basic typing', () => { const tests = [ // [Typed value, Masked value] ['1', '1'], ['18', '18'], ['181', '18.1'], ['1811', '18.11'], ['18112', '18.11.2'], ['18112016', '18.11.2016'], ['181120162', '18.11.2016, 2'], ['1811201623', '18.11.2016, 23'], ['1811201623:', '18.11.2016, 23:'], ['18112016231', '18.11.2016, 23:1'], ['181120162315', '18.11.2016, 23:15'], ['181120162315:', '18.11.2016, 23:15:'], ['1811201623152', '18.11.2016, 23:15:2'], ['18112016231522', '18.11.2016, 23:15:22'], ['18112016231522.', '18.11.2016, 23:15:22.'], ['18112016231522123', '18.11.2016, 23:15:22.123'], ['0', '0'], ['00', '0'], ] as const; tests.forEach(([typedValue, maskedValue]) => { it(`Type "${typedValue}" => "${maskedValue}"`, () => { cy.get('@input') .type(typedValue) .should('have.value', maskedValue) .should('have.prop', 'selectionStart', maskedValue.length) .should('have.prop', 'selectionEnd', maskedValue.length); }); }); }); describe('invalid date cases', () => { it('Empty input => Type "9" => "09|"', () => { cy.get('@input') .type('9') .should('have.value', '09') .should('have.prop', 'selectionStart', '09'.length) .should('have.prop', 'selectionEnd', '09'.length); }); it('27| => type 2 => 27.02|', () => { cy.get('@input') .type('27') .should('have.value', '27') .should('have.prop', 'selectionStart', '27'.length) .should('have.prop', 'selectionEnd', '27'.length) .type('2') .should('have.value', '27.02') .should('have.prop', 'selectionStart', '27.02'.length) .should('have.prop', 'selectionEnd', '27.02'.length); }); it('3| => Type 7 => 03.07|', () => { cy.get('@input') .type('3') .should('have.value', '3') .should('have.prop', 'selectionStart', '3'.length) .should('have.prop', 'selectionEnd', '3'.length) .type('7') .should('have.value', '03.07') .should('have.prop', 'selectionStart', '03.07'.length) .should('have.prop', 'selectionEnd', '03.07'.length); }); }); describe('invalid time cases', () => { beforeEach(() => { cy.get('@input').type('10102020'); }); it('"10.10.2020" => Type "9" => "10.10.2020, 09|"', () => { cy.get('@input') .type('9') .should('have.value', '10.10.2020, 09') .should('have.prop', 'selectionStart', '10.10.2020, 09'.length) .should('have.prop', 'selectionEnd', '10.10.2020, 09'.length); }); it('"10.10.2020, 10" => Type "9" => "10.10.2020, 10:09|"', () => { cy.get('@input') .type('10') .should('have.value', '10.10.2020, 10') .should('have.prop', 'selectionStart', '10.10.2020, 10'.length) .should('have.prop', 'selectionEnd', '10.10.2020, 10'.length) .type('9') .should('have.value', '10.10.2020, 10:09') .should('have.prop', 'selectionStart', '10.10.2020, 10:09'.length) .should('have.prop', 'selectionEnd', '10.10.2020, 10:09'.length); }); it('"10.10.2020, 2" => Type "7" => no value changes', () => { cy.get('@input') .type('2') .should('have.value', '10.10.2020, 2') .should('have.prop', 'selectionStart', '10.10.2020, 2'.length) .should('have.prop', 'selectionEnd', '10.10.2020, 2'.length) .type('7') .should('have.value', '10.10.2020, 2') .should('have.prop', 'selectionStart', '10.10.2020, 2'.length) .should('have.prop', 'selectionEnd', '10.10.2020, 2'.length); }); }); describe('basic erasing (value = "20.01.1990, 15:40:20" & caret is placed after the last value)', () => { beforeEach(() => { cy.get('@input').type('20011990154020'); }); const tests = [ // [How many times "Backspace"-key was pressed, caretPosition, Masked value] [1, '20.01.1990, 15:40:2'.length, '20.01.1990, 15:40:2'], [4, '20.01.1990, 15'.length, '20.01.1990, 15'], [5, '20.01.1990, 1'.length, '20.01.1990, 1'], [6, '20.01.1990'.length, '20.01.1990'], [8, '20.01.19'.length, '20.01.19'], [12, '20'.length, '20'], [13, '2'.length, '2'], ] as const; tests.forEach(([n, caretIndex, maskedValue]) => { it(`Backspace x${n} => "${maskedValue}"`, () => { cy.get('@input') .type('{backspace}'.repeat(n)) .should('have.value', maskedValue) .should('have.prop', 'selectionStart', caretIndex) .should('have.prop', 'selectionEnd', caretIndex); }); }); it('Delete => no value change && no caret index change', () => { cy.get('@input') .type('{del}') .should('have.value', '20.01.1990, 15:40:20') .should('have.prop', 'selectionStart', '20.01.1990, 15:40:20'.length) .should('have.prop', 'selectionEnd', '20.01.1990, 15:40:20'.length); }); it('Type `deleteWordBackward` of `InputEvent` works', () => { cy.get('@input') .type('{ctrl+backspace}') .should('have.value', '20.01.1990') .should('have.prop', 'selectionStart', '20.01.1990'.length) .should('have.prop', 'selectionEnd', '20.01.1990'.length) .type('{ctrl+backspace}') .should('have.value', '') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); }); describe('Editing somewhere in the middle of a value (NOT the last character)', () => { it('25.02.19|99, 15:35 => Backspace => 25.02.1|099, 15:35 => Type "8" => 25.02.18|99, 15:35', () => { cy.get('@input') .type('250219991535') .should('have.value', '25.02.1999, 15:35') .type('{leftArrow}'.repeat('99, 15:35'.length)) .should('have.prop', 'selectionStart', '25.02.19'.length) .should('have.prop', 'selectionEnd', '25.02.19'.length) .type('{backspace}') .should('have.value', '25.02.1099, 15:35') .should('have.prop', 'selectionStart', '25.02.1'.length) .should('have.prop', 'selectionEnd', '25.02.1'.length) .type('8') .should('have.value', '25.02.1899, 15:35') .should('have.prop', 'selectionStart', '25.02.18'.length) .should('have.prop', 'selectionEnd', '25.02.18'.length); }); it('13.06.1736, 15:05|:20 => Backspace => 13.06.1736, 15:0|0:20 => Type "3" => 13.06.1736, 15:03:20', () => { cy.get('@input') .type('13061736150520') .should('have.value', '13.06.1736, 15:05:20') .type('{leftArrow}'.repeat(':20'.length)) .should('have.prop', 'selectionStart', '13.06.1736, 15:05'.length) .should('have.prop', 'selectionEnd', '13.06.1736, 15:05'.length) .type('{backspace}') .should('have.value', '13.06.1736, 15:00:20') .should('have.prop', 'selectionStart', '13.06.1736, 15:0'.length) .should('have.prop', 'selectionEnd', '13.06.1736, 15:0'.length) .type('3') .should('have.value', '13.06.1736, 15:03:20') .should('have.prop', 'selectionStart', '13.06.1736, 15:03:'.length) .should('have.prop', 'selectionEnd', '13.06.1736, 15:03:'.length); }); it('12.|12.2010, 12:30 => Type "9" => 12.09.|2010, 12:30', () => { cy.get('@input') .type('121220101230') .should('have.value', '12.12.2010, 12:30') .type('{leftArrow}'.repeat('12.2010, 12:30'.length)) .should('have.prop', 'selectionStart', '12.'.length) .should('have.prop', 'selectionEnd', '12.'.length) .type('9') .should('have.value', '12.09.2010, 12:30') .should('have.prop', 'selectionStart', '12.09.'.length) .should('have.prop', 'selectionEnd', '12.09.'.length); }); it('12.12.2010, |12:30 => Type "9" => 12.12.2010, 09|:30', () => { cy.get('@input') .type('121220101230') .should('have.value', '12.12.2010, 12:30') .type('{leftArrow}'.repeat('12:30'.length)) .should('have.prop', 'selectionStart', '12.12.2010, '.length) .should('have.prop', 'selectionEnd', '12.12.2010, '.length) .type('9') .should('have.value', '12.12.2010, 09:30') .should('have.prop', 'selectionStart', '12.12.2010, 09:'.length) .should('have.prop', 'selectionEnd', '12.12.2010, 09:'.length); }); }); describe('Text selection', () => { describe('Select range and press Backspace / Delete', () => { it( '10.|12|.2005, 12:30 => Backspace => 10.|01.2005, 12:30', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('101220051230') .should('have.value', '10.12.2005, 12:30') .type('{leftArrow}'.repeat('.2005, 12:30'.length)) .realPress([ 'Shift', ...repeatKey('ArrowLeft', '12'.length), 'Backspace', ]); cy.get('@input') .should('have.value', '10.01.2005, 12:30') .should('have.prop', 'selectionStart', '10.'.length) .should('have.prop', 'selectionEnd', '10.'.length); }, ); it( '10.12.2005, |12|:30 => Backspace => 10.12.2005, |00:30', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('101220051230') .should('have.value', '10.12.2005, 12:30') .type('{leftArrow}'.repeat(':30'.length)) .realPress([ 'Shift', ...repeatKey('ArrowLeft', '12'.length), 'Backspace', ]); cy.get('@input') .should('have.value', '10.12.2005, 00:30') .should('have.prop', 'selectionStart', '10.12.2005, '.length) .should('have.prop', 'selectionEnd', '10.12.2005, '.length); }, ); it( '1|1.1|1.2011, 12:30 => Delete => 10.0|1.2011, 12:30', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('111120111230') .should('have.value', '11.11.2011, 12:30') .type('{leftArrow}'.repeat('1.2011, 12:30'.length)) .realPress(['Shift', ...repeatKey('ArrowLeft', '1.1'.length)]); cy.get('@input') .type('{del}') .should('have.value', '10.01.2011, 12:30') .should('have.prop', 'selectionStart', '10.0'.length) .should('have.prop', 'selectionEnd', '10.0'.length); }, ); it( '11.11.2011, 1|2:3|0 => Delete => 11.11.2011, 10:0|0', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('111120111230') .should('have.value', '11.11.2011, 12:30') .type('{leftArrow}'.repeat('0'.length)) .realPress(['Shift', ...repeatKey('ArrowLeft', '2.3'.length)]); cy.get('@input') .type('{del}') .should('have.value', '11.11.2011, 10:00') .should('have.prop', 'selectionStart', '11.11.2011, 10:0'.length) .should('have.prop', 'selectionEnd', '11.11.2011, 10:0'.length); }, ); }); describe('Select range and press new digit', () => { it( '|12|.11.2022 (specifically do not completes value) => Press 3 => 3|0.11.2022', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('12112022') .type('{leftArrow}'.repeat('.11.2022'.length)) .realPress(['Shift', ...repeatKey('ArrowLeft', '12'.length)]); cy.get('@input') .type('3') .should('have.value', '30.11.2022') .should('have.prop', 'selectionStart', '3'.length) .should('have.prop', 'selectionEnd', '3'.length); }, ); it( '01.01.2000, |12|:30 => Press 2 => 01.01.2000, 2|0:30', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('010120001230') .type('{leftArrow}'.repeat(':30'.length)) .realPress(['Shift', ...repeatKey('ArrowLeft', '12'.length)]); cy.get('@input') .type('2') .should('have.value', '01.01.2000, 20:30') .should('have.prop', 'selectionStart', '01.01.2000, 2'.length) .should('have.prop', 'selectionEnd', '01.01.2000, 2'.length); }, ); it( '01.01.2000, 1|2|:30 => Press 5 => 01.01.2000, 15:|30', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('010120001230') .type('{leftArrow}'.repeat(':30'.length)) .realPress(['Shift', 'ArrowLeft']); cy.get('@input') .type('5') .should('have.value', '01.01.2000, 15:30') .should('have.prop', 'selectionStart', '01.01.2000, 15:'.length) .should('have.prop', 'selectionEnd', '01.01.2000, 15:'.length); }, ); }); }); describe('Paste', () => { it('value without segment separators', () => { cy.get('@input') .paste('02112018, 16:20') .should('have.value', '02.11.2018, 16:20') .should('have.prop', 'selectionStart', '02.11.2018, 16:20'.length) .should('have.prop', 'selectionEnd', '02.11.2018, 16:20'.length); }); it('value without separator between date and time', () => { cy.get('@input') .paste('02.11.201816:20') .should('have.value', '02.11.2018, 16:20') .should('have.prop', 'selectionStart', '02.11.2018, 16:20'.length) .should('have.prop', 'selectionEnd', '02.11.2018, 16:20'.length); }); it('value with incomplete separator between date and time', () => { cy.get('@input') .paste('02.11.2018,16:20') .should('have.value', '02.11.2018, 16:20') .should('have.prop', 'selectionStart', '02.11.2018, 16:20'.length) .should('have.prop', 'selectionEnd', '02.11.2018, 16:20'.length); }); it('value without any separators', () => { cy.get('@input') .paste('021120181620') .should('have.value', '02.11.2018, 16:20') .should('have.prop', 'selectionStart', '02.11.2018, 16:20'.length) .should('have.prop', 'selectionEnd', '02.11.2018, 16:20'.length); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/date-time/date-time-date-time-separator.cy.ts ================================================ import {DemoPath} from '@demo/constants'; import type {MaskitoTimeMode} from '@maskito/kit'; describe('DateTime | dateTimeSeparator', () => { const dateTimeSeparators = [':', ';_', '_-_', '_at_']; dateTimeSeparators.forEach((dateTimeSeparator) => { const testCases: Array<{ typedDigits: string; formattedDate: string; formattedValue: string; timeMode: MaskitoTimeMode; }> = [ { typedDigits: '522004341', formattedValue: `05.02.2004${dateTimeSeparator}03:41`, formattedDate: '05.02.2004', timeMode: 'HH:MM', }, { typedDigits: '233123434111', formattedValue: `23.03.1234${dateTimeSeparator}03:41:11`, formattedDate: '23.03.1234', timeMode: 'HH:MM:SS', }, { typedDigits: '69200734111111', formattedValue: `06.09.2007${dateTimeSeparator}03:41:11.111`, formattedDate: '06.09.2007', timeMode: 'HH:MM:SS.MSS', }, ]; describe(`correctly applies "${dateTimeSeparator}" as dateTimeSeparator`, () => { testCases.forEach( ({typedDigits, formattedDate, formattedValue, timeMode}) => { const timeDigitsCount = timeMode.replaceAll(/[:.]/g, '').length; beforeEach(() => { cy.visit( `/${DemoPath.DateTime}/API?dateTimeSeparator=${encodeURIComponent(dateTimeSeparator)}&timeMode=${encodeURIComponent(timeMode)}`, ); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it(`${typedDigits} => ${formattedValue} => {backspace} * ${timeDigitsCount} => ${formattedDate}`, () => { cy.get('@input') .type(typedDigits) .should('have.value', formattedValue) .type('{backspace}'.repeat(timeDigitsCount)) .should('have.value', formattedDate); }); }, ); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/date-time/date-time-fullwidth-to-halfwidth.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('DateTime | Full width character parsing', () => { beforeEach(() => { cy.visit( `/${DemoPath.DateTime}/API?dateMode=yyyy%2Fmm%2Fdd&timeMode=HH:MM:SS&dateSeparator=%2F`, ); cy.get('#demo-content input').should('be.visible').first().focus().as('input'); }); describe('basic typing', () => { const tests = [ // [Typed value, Masked value] ['2', '2'], ['20', '20'], ['201', '201'], ['2016', '2016'], ['20162', '2016/02'], ['2016228', '2016/02/28'], ['20162283', '2016/02/28, 03'], ['2016228330', '2016/02/28, 03:30'], ['20162283304', '2016/02/28, 03:30:4'], ['201622833045', '2016/02/28, 03:30:45'], ] as const; tests.forEach(([typedValue, maskedValue]) => { it(`Type "${typedValue}" => "${maskedValue}"`, () => { cy.get('@input') .type(typedValue) .should('have.value', maskedValue) .should('have.prop', 'selectionStart', maskedValue.length) .should('have.prop', 'selectionEnd', maskedValue.length); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/date-time/date-time-meridiem.cy.ts ================================================ import {DemoPath} from '@demo/constants'; import {range, withCaretLabel} from '../../utils'; describe('DateTime | time modes with meridiem', () => { describe('HH:MM AA', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateTime}/API?timeMode=HH:MM%20AA`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('textfield'); }); describe('basic text insertion works', () => { it('Empty textfield => Type 1234AM => 12:34 AM', () => { cy.get('@textfield') .type('9920001234AM') .should('have.value', '09.09.2000, 12:34 AM') .should('have.prop', 'selectionStart', '09.09.2000, 12:34 AM'.length) .should('have.prop', 'selectionEnd', '09.09.2000, 12:34 AM'.length); }); it('12:34| => Type lowercase `a` => 12:34 AM', () => { cy.get('@textfield') .type('01.01.20001234a') .should('have.value', '01.01.2000, 12:34 AM') .should('have.prop', 'selectionStart', '01.01.2000, 12:34 AM'.length) .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 AM'.length); }); it('12:34| => Type uppercase `A` => 12:34 AM', () => { cy.get('@textfield') .type('01.01.20001234A') .should('have.value', '01.01.2000, 12:34 AM') .should('have.prop', 'selectionStart', '01.01.2000, 12:34 AM'.length) .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 AM'.length); }); it('12:34| => Type lowercase `p` => 12:34 PM', () => { cy.get('@textfield') .type('01.01.20001234p') .should('have.value', '01.01.2000, 12:34 PM') .should('have.prop', 'selectionStart', '01.01.2000, 12:34 PM'.length) .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 PM'.length); }); it('12:34| => Type uppercase `P` => 12:34 PM', () => { cy.get('@textfield') .type('01.01.20001234P') .should('have.value', '01.01.2000, 12:34 PM') .should('have.prop', 'selectionStart', '01.01.2000, 12:34 PM'.length) .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 PM'.length); }); it('12:34| => Type lowercase `m` => 12:34|', () => { cy.get('@textfield') .type('01.01.20001234m') .should('have.value', '01.01.2000, 12:34') .should('have.prop', 'selectionStart', '01.01.2000, 12:34'.length) .should('have.prop', 'selectionEnd', '01.01.2000, 12:34'.length); }); it('12:34| => Type uppercase `M` => 12:34|', () => { cy.get('@textfield') .type('01.01.20001234M') .should('have.value', '01.01.2000, 12:34') .should('have.prop', 'selectionStart', '01.01.2000, 12:34'.length) .should('have.prop', 'selectionEnd', '01.01.2000, 12:34'.length); }); }); describe('deletion of any meridiem characters deletes all meridiem character', () => { [ {caretIndex: '01.01.2000, 12:34 AM'.length, action: '{backspace}'}, {caretIndex: '01.01.2000, 12:34 A'.length, action: '{backspace}'}, {caretIndex: '01.01.2000, 12:34 '.length, action: '{del}'}, {caretIndex: '01.01.2000, 12:34 A'.length, action: '{del}'}, ].forEach(({caretIndex, action}) => { const initialValue = '01.01.2000, 12:34 AM'; it(`${withCaretLabel(initialValue, caretIndex)} => ${action} => 12:34|`, () => { cy.get('@textfield') .type('01.01.2000 1234a') .should('have.value', initialValue) .type('{moveToStart}') .type('{rightArrow}'.repeat(caretIndex)) .type(action) .should('have.value', '01.01.2000, 12:34') .should('have.prop', 'selectionStart', '01.01.2000, 12:34'.length) .should('have.prop', 'selectionEnd', '01.01.2000, 12:34'.length); }); }); }); describe('type new meridiem value when textfield already has another one', () => { it('12:34 AM| => Type P => 12:34 PM|', () => { cy.get('@textfield') .type('01.01.2000 1234a') .should('have.value', '01.01.2000, 12:34 AM') .type('{moveToEnd}') .type('p') .should('have.value', '01.01.2000, 12:34 PM') .should('have.prop', 'selectionStart', '01.01.2000, 12:34 PM'.length) .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 PM'.length); }); it('12:34 A|M => Type P => 12:34 PM|', () => { cy.get('@textfield') .type('01.01.2000 1234a') .should('have.value', '01.01.2000, 12:34 AM') .type('{moveToEnd}{leftArrow}') .type('p') .should('have.value', '01.01.2000, 12:34 PM') .should('have.prop', 'selectionStart', '01.01.2000, 12:34 PM'.length) .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 PM'.length); }); it('12:34 |AM => Type P => 12:34 PM|', () => { cy.get('@textfield') .type('01.01.2000 1234a') .should('have.value', '01.01.2000, 12:34 AM') .type('{moveToEnd}') .type('{leftArrow}'.repeat(2)) .type('p') .should('have.value', '01.01.2000, 12:34 PM') .should('have.prop', 'selectionStart', '01.01.2000, 12:34 PM'.length) .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 PM'.length); }); it('12:34| AM => Type P => 12:34 PM|', () => { cy.get('@textfield') .type('01.01.2000 1234a') .should('have.value', '01.01.2000, 12:34 AM') .type('{moveToEnd}') .type('{leftArrow}'.repeat(' AM'.length)) .type('p') .should('have.value', '01.01.2000, 12:34 PM') .should('have.prop', 'selectionStart', '01.01.2000, 12:34 PM'.length) .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 PM'.length); }); it('12:34 PM| => Type A => 12:34 AM|', () => { cy.get('@textfield') .type('01.01.2000 1234p') .should('have.value', '01.01.2000, 12:34 PM') .type('{moveToEnd}') .type('a') .should('have.value', '01.01.2000, 12:34 AM') .should('have.prop', 'selectionStart', '01.01.2000, 12:34 AM'.length) .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 AM'.length); }); it('12:34 P|M => Type A => 12:34 AM|', () => { cy.get('@textfield') .type('01.01.2000 1234p') .should('have.value', '01.01.2000, 12:34 PM') .type('{moveToEnd}{leftArrow}') .type('A') .should('have.value', '01.01.2000, 12:34 AM') .should('have.prop', 'selectionStart', '01.01.2000, 12:34 AM'.length) .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 AM'.length); }); it('12:34 |PM => Type A => 12:34 AM|', () => { cy.get('@textfield') .type('01.01.2000 1234p') .should('have.value', '01.01.2000, 12:34 PM') .type('{moveToEnd}') .type('{leftArrow}'.repeat(2)) .type('a') .should('have.value', '01.01.2000, 12:34 AM') .should('have.prop', 'selectionStart', '01.01.2000, 12:34 AM'.length) .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 AM'.length); }); it('12:34| PM => Type A => 12:34 AM|', () => { cy.get('@textfield') .type('01.01.2000 1234p') .should('have.value', '01.01.2000, 12:34 PM') .type('{moveToEnd}') .type('{leftArrow}'.repeat(' PM'.length)) .type('a') .should('have.value', '01.01.2000, 12:34 AM') .should('have.prop', 'selectionStart', '01.01.2000, 12:34 AM'.length) .should('have.prop', 'selectionEnd', '01.01.2000, 12:34 AM'.length); }); }); describe('hour segment bounds', () => { const beforeTimeValue = '01.01.2000, '; beforeEach(() => { cy.get('@textfield') .type('01.01.2000 ') .should('have.value', beforeTimeValue); }); it('cannot be less than 01 (rejects zero as the 2nd hour segment)', () => { cy.get('@textfield') .type('00') .should('have.value', `${beforeTimeValue}0`) .should('have.prop', 'selectionStart', beforeTimeValue.length + 1) .should('have.prop', 'selectionEnd', beforeTimeValue.length + 1); }); it('can be 1 (as the 1st digit segment)', () => { cy.get('@textfield') .type('1') .should('have.value', `${beforeTimeValue}1`) .should('have.prop', 'selectionStart', beforeTimeValue.length + 1) .should('have.prop', 'selectionEnd', beforeTimeValue.length + 1); }); describe('automatically pads with zero', () => { range(2, 9).forEach((x) => { it(`on attempt to enter ${x} as the first hour segment`, () => { cy.get('@textfield') .type(String(x)) .should('have.value', `${beforeTimeValue}0${x}`) .should( 'have.prop', 'selectionStart', beforeTimeValue.length + 2, ) .should( 'have.prop', 'selectionEnd', beforeTimeValue.length + 2, ); }); }); }); range(10, 12).forEach((x) => { const value = String(x); it(`can be ${x}`, () => { cy.get('@textfield') .type(value) .should('have.value', `${beforeTimeValue}${value}`) .should('have.prop', 'selectionStart', beforeTimeValue.length + 2) .should('have.prop', 'selectionEnd', beforeTimeValue.length + 2); }); }); describe('rejects insertion', () => { range(13, 19).forEach((x) => { it(`on attempt to enter ${x} as the last hour segment`, () => { cy.get('@textfield') .type(String(x)) .should('have.value', `${beforeTimeValue}1`) .should( 'have.prop', 'selectionStart', beforeTimeValue.length + 1, ) .should( 'have.prop', 'selectionEnd', beforeTimeValue.length + 1, ); }); }); }); }); describe('toggle meridiem value on ArrowUp / ArrowDown', () => { describe('Initial value === "12:34 |"', () => { const beforeMeridiemValue = '01.01.2000, 12:34 '; beforeEach(() => { cy.get('@textfield') .type('01.01.2000 1234 ') .should('have.value', beforeMeridiemValue); }); it('↑ --- 12:34 |AM', () => { cy.get('@textfield') .type('{upArrow}') .should('have.value', `${beforeMeridiemValue}AM`) .should('have.prop', 'selectionStart', beforeMeridiemValue.length) .should('have.prop', 'selectionEnd', beforeMeridiemValue.length); }); it('↓ --- 12:34 |PM', () => { cy.get('@textfield') .type('{downArrow}') .should('have.value', `${beforeMeridiemValue}PM`) .should('have.prop', 'selectionStart', beforeMeridiemValue.length) .should('have.prop', 'selectionEnd', beforeMeridiemValue.length); }); }); describe('Initial value === "12:34 AM"', () => { const beforeTimeValue = '01.01.2000, '; const initialValue = `${beforeTimeValue}12:34 AM`; const toggledValue = `${beforeTimeValue}12:34 PM`; beforeEach(() => { cy.get('@textfield') .type('01.01.2000 1234a') .should('have.value', initialValue) .type('{moveToStart}'); }); [ `${beforeTimeValue}12:34 `.length, `${beforeTimeValue}12:34 A`.length, `${beforeTimeValue}12:34 AM`.length, ].forEach((initialCaretIndex) => { const initialValueWithCaretLabel = withCaretLabel( initialValue, initialCaretIndex, ); const toggledValueWithCaretLabel = withCaretLabel( toggledValue, initialCaretIndex, ); it(`${initialValueWithCaretLabel} --- ↑ --- ${toggledValueWithCaretLabel}`, () => { cy.get('@textfield') .type('{rightArrow}'.repeat(initialCaretIndex)) .type('{upArrow}') .should('have.value', toggledValue) .should('have.prop', 'selectionStart', initialCaretIndex) .should('have.prop', 'selectionEnd', initialCaretIndex); }); it(`${initialValueWithCaretLabel} --- ↓ --- ${toggledValueWithCaretLabel}`, () => { cy.get('@textfield') .type('{rightArrow}'.repeat(initialCaretIndex)) .type('{downArrow}') .should('have.value', toggledValue) .should('have.prop', 'selectionStart', initialCaretIndex) .should('have.prop', 'selectionEnd', initialCaretIndex); }); }); }); describe('Initial value === "01:01 PM"', () => { const beforeTimeValue = '01.01.2000, '; const initialValue = `${beforeTimeValue}01:01 PM`; const toggledValue = `${beforeTimeValue}01:01 AM`; beforeEach(() => { cy.get('@textfield') .type('01.01.2000 0101p') .should('have.value', initialValue) .type('{moveToStart}'); }); [ `${beforeTimeValue}01:01 `.length, `${beforeTimeValue}01:01 P`.length, `${beforeTimeValue}01:01 PM`.length, ].forEach((initialCaretIndex) => { const initialValueWithCaretLabel = withCaretLabel( initialValue, initialCaretIndex, ); const toggledValueWithCaretLabel = withCaretLabel( toggledValue, initialCaretIndex, ); it(`${initialValueWithCaretLabel} --- ↑ --- ${toggledValueWithCaretLabel}`, () => { cy.get('@textfield') .type('{rightArrow}'.repeat(initialCaretIndex)) .type('{upArrow}') .should('have.value', toggledValue) .should('have.prop', 'selectionStart', initialCaretIndex) .should('have.prop', 'selectionEnd', initialCaretIndex); }); it(`${initialValueWithCaretLabel} --- ↓ --- ${toggledValueWithCaretLabel}`, () => { cy.get('@textfield') .type('{rightArrow}'.repeat(initialCaretIndex)) .type('{downArrow}') .should('have.value', toggledValue) .should('have.prop', 'selectionStart', initialCaretIndex) .should('have.prop', 'selectionEnd', initialCaretIndex); }); }); }); describe('do nothing when caret is put after any time segment', () => { it('Empty time part --- ↑↓ --- Empty time part', () => { cy.get('@textfield') .type('01.01.2000 ') .should('have.value', '01.01.2000, ') .type('{upArrow}') .should('have.value', '01.01.2000, ') .type('{downArrow}') .should('have.value', '01.01.2000, '); }); ['1', '12', '12:', '12:3', '12:34'].forEach((textfieldValue) => { it(`${textfieldValue} --- ↑↓ --- ${textfieldValue}`, () => { cy.get('@textfield') .type(`01.01.2000 ${textfieldValue}`) .should('have.value', `01.01.2000, ${textfieldValue}`) .type('{upArrow}') .should('have.value', `01.01.2000, ${textfieldValue}`) .type('{downArrow}') .should('have.value', `01.01.2000, ${textfieldValue}`); }); }); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/date-time/date-time-min-max.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('DateTime | Min & Max dates', () => { describe('Max', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateTime}/API?max=2020-05-05T12:20`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('accepts date less than max value', () => { cy.get('@input') .type('18.12.2019,23:50') .should('have.value', '18.12.2019, 23:50') .should('have.prop', 'selectionStart', '18.12.2019, 23:50'.length) .should('have.prop', 'selectionEnd', '18.12.2019, 23:50'.length); }); it('05.05.2020, 12:2| => Type 5 => 05.05.2020, 12:20 (max value)', () => { cy.get('@input') .type('05.05.2020,12:2') .should('have.value', '05.05.2020, 12:2') .should('have.prop', 'selectionStart', '05.05.2020, 12:2'.length) .should('have.prop', 'selectionEnd', '05.05.2020, 12:2'.length) .type('5') .should('have.value', '05.05.2020, 12:20') .should('have.prop', 'selectionStart', '05.05.2020, 12:20'.length) .should('have.prop', 'selectionEnd', '05.05.2020, 12:20'.length); }); it('18.12.20|19, 12:20 => Type 2 => 05.05.202|0, 12:20 (max value)', () => { cy.get('@input') .type('18.12.2019,12:20') .type('{leftArrow}'.repeat('19, 12:20'.length)) .type('2') .should('have.value', '05.05.2020, 12:20') .should('have.prop', 'selectionStart', '05.05.202'.length) .should('have.prop', 'selectionEnd', '05.05.202'.length); }); describe('Correct value after date only input', () => { it('06.06.202| => Type 5 => 05.05.2020| (max value)', () => { cy.get('@input') .type('06.06.202') .should('have.value', '06.06.202') .should('have.prop', 'selectionStart', '06.06.202'.length) .should('have.prop', 'selectionEnd', '06.06.202'.length) .type('5') .should('have.value', '05.05.2020') .should('have.prop', 'selectionStart', '05.05.2020'.length) .should('have.prop', 'selectionEnd', '05.05.2020'.length); }); it('0|1.05.2020 => Type 9 => 05|.05.2020 (max value)', () => { cy.get('@input') .type('01052020') .type('{leftArrow}'.repeat('1.05.2020'.length)) .type('9') .should('have.value', '05.05.2020') .should('have.prop', 'selectionStart', '05.'.length); }); }); }); describe('Min', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateTime}/API?min=1995-10-14T15:32`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('accepts date more than min value', () => { cy.get('@input') .type('13.04.2001,11:23') .should('have.value', '13.04.2001, 11:23') .should('have.prop', 'selectionStart', '13.04.2001, 11:23'.length) .should('have.prop', 'selectionEnd', '13.04.2001, 11:23'.length); }); it('14.10.1995, 15:3| => Type 1 => 14.10.1995, 15:32 (min)', () => { cy.get('@input') .type('14.10.1995,15:3') .should('have.value', '14.10.1995, 15:3') .should('have.prop', 'selectionStart', '14.10.1995, 15:3'.length) .should('have.prop', 'selectionEnd', '14.10.1995, 15:3'.length) .type('1') .should('have.value', '14.10.1995, 15:32') .should('have.prop', 'selectionStart', '14.10.1995, 15:32'.length) .should('have.prop', 'selectionEnd', '14.10.1995, 15:32'.length); }); it('14.|10.1995, 10:20 => Type 9 => 14.10.|1995, 15:32 (min)', () => { cy.get('@input') .type('14.10.1995,10:20') .type('{leftArrow}'.repeat('10.1995, 10:20'.length)) .type('9') .should('have.value', '14.10.1995, 15:32') .should('have.prop', 'selectionStart', '14.10.'.length) .should('have.prop', 'selectionEnd', '14.10.'.length); }); describe('Correct value after date only input', () => { it('14.10.199 => Type 3 => 14.10.1995| (min)', () => { cy.get('@input') .type('14.10.199') .should('have.value', '14.10.199') .should('have.prop', 'selectionStart', '14.10.199'.length) .should('have.prop', 'selectionEnd', '14.10.199'.length) .type('3') .should('have.value', '14.10.1995') .should('have.prop', 'selectionStart', '14.10.1995'.length) .should('have.prop', 'selectionEnd', '14.10.1995'.length); }); it('14.10.199|5 => Type 3 => 14.10.1995| (min)', () => { cy.get('@input') .type('14.10.1995') .type('{leftArrow}') .type('3') .should('have.value', '14.10.1995') .should('have.prop', 'selectionStart', '14.10.1995'.length); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/date-time/date-time-mode.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('DateTime | mode', () => { describe('Date mode', () => { describe('mm.dd.yyyy', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateTime}/API?dateMode=mm%2Fdd%2Fyyyy`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('12.31.2000, 13:20', () => { cy.get('@input') .type('123120001320') .should('have.value', '12.31.2000, 13:20') .should('have.prop', 'selectionStart', '12.31.2000, 13:20'.length) .should('have.prop', 'selectionEnd', '12.31.2000, 13:20'.length); }); it('Empty input => Type 3 => 03|', () => { cy.get('@input') .type('3') .should('have.value', '03') .should('have.prop', 'selectionStart', '03'.length) .should('have.prop', 'selectionEnd', '03'.length); }); it('12| => Type 3 => 12.3|', () => { cy.get('@input') .type('123') .should('have.value', '12.3') .should('have.prop', 'selectionStart', '12.3'.length) .should('have.prop', 'selectionEnd', '12.3'.length); }); it('12| => Type 4 => 12.04|', () => { cy.get('@input') .type('124') .should('have.value', '12.04') .should('have.prop', 'selectionStart', '12.04'.length) .should('have.prop', 'selectionEnd', '12.04'.length); }); }); describe('yyyy.mm.dd', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateTime}/API?dateMode=yyyy%2Fmm%2Fdd`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('2000.12.31, 12:20', () => { cy.get('@input') .type('20001231,1220') .should('have.value', '2000.12.31, 12:20') .should('have.prop', 'selectionStart', '2000.12.31, 12:20'.length) .should('have.prop', 'selectionEnd', '2000.12.31, 12:20'.length); }); it('2000| => Type 3 => 2000.03|', () => { cy.get('@input') .type('20003') .should('have.value', '2000.03') .should('have.prop', 'selectionStart', '2000.03'.length) .should('have.prop', 'selectionEnd', '2000.03'.length); }); it('2000.03| => Type 5 => 2000.03.05|', () => { cy.get('@input') .type('200035') .should('have.value', '2000.03.05') .should('have.prop', 'selectionStart', '2000.03.05'.length) .should('have.prop', 'selectionEnd', '2000.03.05'.length); }); }); }); describe('Time', () => { describe('HH:MM', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateTime}/API?timeMode=HH:MM`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('12.01.2000, 13:20 => type 12 => no value changes', () => { cy.get('@input') .type('120120001320') .should('have.value', '12.01.2000, 13:20') .should('have.prop', 'selectionStart', '12.01.2000, 13:20'.length) .should('have.prop', 'selectionEnd', '12.01.2000, 13:20'.length) .type('12') .should('have.value', '12.01.2000, 13:20') .should('have.prop', 'selectionStart', '12.01.2000, 13:20'.length) .should('have.prop', 'selectionEnd', '12.01.2000, 13:20'.length); }); }); describe('HH:MM:SS', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateTime}/API?timeMode=HH:MM:SS`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('12.01.2000, 13:20:30 => type 12 => no value changes', () => { cy.get('@input') .type('12012000132030') .should('have.value', '12.01.2000, 13:20:30') .should('have.prop', 'selectionStart', '12.01.2000, 13:20:30'.length) .should('have.prop', 'selectionEnd', '12.01.2000, 13:20:30'.length) .type('12') .should('have.value', '12.01.2000, 13:20:30') .should('have.prop', 'selectionStart', '12.01.2000, 13:20:30'.length) .should('have.prop', 'selectionEnd', '12.01.2000, 13:20:30'.length); }); }); describe('HH:MM:SS.MSS', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateTime}/API?timeMode=HH:MM:SS.MSS`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('12.01.2000, 13:20:30.123', () => { cy.get('@input') .type('12012000132030123') .should('have.value', '12.01.2000, 13:20:30.123') .should( 'have.prop', 'selectionStart', '12.01.2000, 13:20:30.123'.length, ) .should( 'have.prop', 'selectionEnd', '12.01.2000, 13:20:30.123'.length, ); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/date-time/date-time-separator.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('DateTime | Separator', () => { describe('/', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateTime}/API?dateSeparator=/`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('14/12/1997', () => { cy.get('@input').type('14121997').should('have.value', '14/12/1997'); }); it('rejects dot as separator', () => { cy.get('@input') .type('1412') .should('have.value', '14/12') .type('2000') .should('have.value', '14/12/2000'); }); it('accepts date segment separators typed by user', () => { cy.get('@input') .type('24') .should('have.value', '24') .type('/') .should('have.value', '24/') .should('have.prop', 'selectionStart', '24/'.length) .should('have.prop', 'selectionEnd', '24/'.length) .type('05/') .should('have.value', '24/05/') .should('have.prop', 'selectionStart', '24/05/'.length) .should('have.prop', 'selectionEnd', '24/05/'.length); }); }); describe('-', () => { beforeEach(() => { cy.visit(`/${DemoPath.DateTime}/API?dateSeparator=-`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); it('14-12-1997', () => { cy.get('@input').type('14121997').should('have.value', '14-12-1997'); }); it('rejects dot as separator', () => { cy.get('@input') .type('14') .should('have.value', '14') .type('12') .should('have.value', '14-12'); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/date-time/date-time-time-step.cy.ts ================================================ import {DemoPath} from '@demo/constants'; import {BROWSER_SUPPORTS_REAL_EVENTS} from 'projects/demo-integrations/src/support/constants'; describe('DateTime | timeStep', () => { describe('yy/mm;HH:MM:SS.MSS', () => { describe('timeStep = 1, initial state = 22.12;', () => { beforeEach(() => { cy.visit( `/${DemoPath.DateTime}/API?dateTimeSeparator=;&dateMode=yy%2Fmm&timeStep=1&timeMode=HH:MM:SS.MSS`, ); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); cy.get('@input') .type('2212;') .should('have.value', '22.12;') .should('have.a.prop', 'selectionStart', '22.12;'.length) .should('have.a.prop', 'selectionEnd', '22.12;'.length); }); it('decrements hours segment by pressing ArrowDown at different places of the segment', () => { cy.get('@input') .type('{downArrow}') .should('have.value', '22.12;23') .should('have.a.prop', 'selectionStart', '22.12;'.length) .should('have.a.prop', 'selectionEnd', '22.12;'.length) .type('{rightArrow}{downArrow}') .should('have.value', '22.12;22') .should('have.a.prop', 'selectionStart', '22.12;2'.length) .should('have.a.prop', 'selectionEnd', '22.12;2'.length) .type('{rightArrow}') .type('{upArrow}'.repeat(12)) .should('have.value', '22.12;10') .should('have.a.prop', 'selectionStart', '22.12;10'.length) .should('have.a.prop', 'selectionEnd', '22.12;10'.length); }); it('increments/decrements minutes segment by pressing keyboard arrows at different places of the segment', () => { cy.get('@input') .type('12:') .should('have.value', '22.12;12:') .should('have.a.prop', 'selectionStart', '22.12;12:'.length) .should('have.a.prop', 'selectionEnd', '22.12;12:'.length) .type('{upArrow}') .should('have.value', '22.12;12:01') .should('have.a.prop', 'selectionStart', '22.12;12:'.length) .should('have.a.prop', 'selectionEnd', '22.12;12:'.length) .type('{rightArrow}') .type('{upArrow}'.repeat(9)) .should('have.value', '22.12;12:10') .should('have.a.prop', 'selectionStart', '22.12;12:1'.length) .should('have.a.prop', 'selectionEnd', '22.12;12:1'.length) .type('{rightArrow}') .type('{downArrow}'.repeat(34)) .should('have.value', '22.12;12:36') .should('have.a.prop', 'selectionStart', '22.12;12:36'.length) .should('have.a.prop', 'selectionEnd', '22.12;12:36'.length); }); it('changes seconds segment by pressing keyboard arrow up/down', () => { cy.get('@input') .type('12:10:') .should('have.value', '22.12;12:10:') .should('have.a.prop', 'selectionStart', '22.12;12:10:'.length) .should('have.a.prop', 'selectionEnd', '22.12;12:10:'.length) .type('{downArrow}'.repeat(6)) .should('have.value', '22.12;12:10:54') .should('have.a.prop', 'selectionStart', '22.12;12:10:'.length) .should('have.a.prop', 'selectionEnd', '22.12;12:10:'.length) .type('{rightArrow}{upArrow}'.repeat(2)) .should('have.value', '22.12;12:10:56') .should('have.a.prop', 'selectionStart', '22.12;12:10:56'.length) .should('have.a.prop', 'selectionEnd', '22.12;12:10:56'.length); }); it('changes milliseconds segment by pressing keyboard arrow up/down', () => { cy.get('@input') .type('213212.') .should('have.value', '22.12;21:32:12.') .should('have.a.prop', 'selectionStart', '22.12;21:32:12.'.length) .should('have.a.prop', 'selectionEnd', '22.12;21:32:12.'.length) .type('{upArrow}{rightArrow}'.repeat(3)) .type('{downArrow}') .should('have.value', '22.12;21:32:12.002') .should('have.a.prop', 'selectionStart', '22.12;21:32:12.002'.length) .should('have.a.prop', 'selectionEnd', '22.12;21:32:12.002'.length); }); it('type 213212. => 22.12;21:32:12.| => type ({downArrow}{rightArrow}) * 3 + {downArrow} => 22.12:21:32:12.995|', () => { cy.get('@input') .type('213212.') .should('have.value', '22.12;21:32:12.') .should('have.a.prop', 'selectionStart', '22.12;21:32:12.'.length) .should('have.a.prop', 'selectionEnd', '22.12;21:32:12.'.length) .type('{downArrow}{rightArrow}'.repeat(3)) .type('{downArrow}') .should('have.value', '22.12;21:32:12.996') .should('have.a.prop', 'selectionStart', '22.12;21:32:12.996'.length) .should('have.a.prop', 'selectionEnd', '22.12;21:32:12.996'.length); }); it('should affect only time segments', () => { cy.get('@input') .type('123456111') .should('have.value', '22.12;12:34:56.111') .should('have.a.prop', 'selectionStart', '22.12;12:34:56.111'.length) .should('have.a.prop', 'selectionEnd', '22.12;12:34:56.111'.length) .type('{upArrow}{leftArrow}'.repeat('22.12;12:34:56.111'.length)) .should('have.value', '22.12;15:37:59.115') .should('have.a.prop', 'selectionStart', 0) .should('have.a.prop', 'selectionEnd', 0); }); }); describe('timeStep = 0 (disabled time stepping)', () => { beforeEach(() => { cy.visit( `/${DemoPath.DateTime}/API?dateTimeSeparator=;&dateMode=yy%2Fmm&timeStep=0&timeMode=HH:MM:SS.MSS`, ); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); cy.get('@input') .type('1202123456000') .should('have.value', '12.02;12:34:56.000') .should('have.a.prop', 'selectionStart', '12.02;12:34:56.000'.length) .should('have.a.prop', 'selectionEnd', '12.02;12:34:56.000'.length); }); it('should be disabled', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input').realPress('ArrowUp'); cy.get('@input') .should('have.a.prop', 'selectionStart', 0) .should('have.a.prop', 'selectionEnd', 0) .realPress('ArrowDown'); cy.get('@input') .should('have.a.prop', 'selectionStart', '12.02;12:34:56.000'.length) .should('have.a.prop', 'selectionEnd', '12.02;12:34:56.000'.length); }); }); }); describe('yy/mm;HH:MM AA', () => { describe('timeStep = 1, initial state = 22/12;', () => { beforeEach(() => { cy.visit( `/${DemoPath.DateTime}/API?dateTimeSeparator=;&dateMode=yy%2Fmm&timeStep=1&timeMode=HH:MM%20AA`, ); cy.get('#demo-content input') .should('be.visible') .first() .focus() .clear() .as('input'); cy.get('@input') .type('2212;') .should('have.value', '22.12;') .should('have.a.prop', 'selectionStart', '22.12;'.length) .should('have.a.prop', 'selectionEnd', '22.12;'.length); }); it('wraps hours correctly when pressing up at hour 12', () => { cy.get('@input') .type('1234p') .should('have.value', '22.12;12:34 PM') .should('have.a.prop', 'selectionStart', '22.12;12:34 PM'.length) .should('have.a.prop', 'selectionEnd', '22.12;12:34 PM'.length) .type('{moveToStart}') .type('{rightArrow}'.repeat('22.12;'.length)) .type('{upArrow}'.repeat(2)) .should('have.a.prop', 'selectionStart', '22.12;'.length) .should('have.a.prop', 'selectionEnd', '22.12;'.length) .should('have.value', '22.12;02:34 PM') .type('{downArrow}'.repeat(4)) .should('have.a.prop', 'selectionStart', '22.12;'.length) .should('have.a.prop', 'selectionEnd', '22.12;'.length) .should('have.value', '22.12;10:34 PM'); }); it('increments and decrements minutes in AM/PM mode correctly', () => { cy.get('@input') .type('1234a') .should('have.value', '22.12;12:34 AM') .should('have.a.prop', 'selectionStart', '22.12;12:34 AM'.length) .should('have.a.prop', 'selectionEnd', '22.12;12:34 AM'.length) .type('{leftArrow}'.repeat(3)) .type('{upArrow}'.repeat(2)) .should('have.value', '22.12;12:36 AM') .type('{downArrow}'.repeat(3)) .should('have.value', '22.12;12:33 AM'); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/number/number-basic.cy.ts ================================================ import {openNumberPage} from './utils'; describe('Number | Basic', () => { beforeEach(() => { openNumberPage('thousandSeparator=_&maximumFractionDigits=2'); }); describe('Invalid characters', () => { it('rejects redundant spaces', () => { cy.get('@input') .type('1 2 3 4 5') .should('have.value', '12_345') .should('have.prop', 'selectionStart', '12_345'.length) .should('have.prop', 'selectionEnd', '12_345'.length); }); it('rejects lowercase latin letters', () => { cy.get('@input') .type('123abcdefghijklmnopqrstuvwxyz456') .should('have.value', '123_456') .should('have.prop', 'selectionStart', '123_456'.length) .should('have.prop', 'selectionEnd', '123_456'.length); }); it('rejects uppercase latin letters', () => { cy.get('@input') .type('123ABCDEFGHIJKLMNOPQRSTUVWXYZ456') .should('have.value', '123_456') .should('have.prop', 'selectionStart', '123_456'.length) .should('have.prop', 'selectionEnd', '123_456'.length); }); it('rejects lowercase cyrillic letters', () => { cy.get('@input') .type('123авгдеёжзийклмнопрстуфхцчшщъыьэя456') // without "б" and "ю" .should('have.value', '123_456') .should('have.prop', 'selectionStart', '123_456'.length) .should('have.prop', 'selectionEnd', '123_456'.length); }); it('rejects uppercase cyrillic letters', () => { cy.get('@input') .type('123АВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЯ456') .should('have.value', '123_456') .should('have.prop', 'selectionStart', '123_456'.length) .should('have.prop', 'selectionEnd', '123_456'.length); }); it('rejects punctuation', () => { cy.get('@input') .type('123!"№%:;()456') // without dot and comma .should('have.value', '123_456') .should('have.prop', 'selectionStart', '123_456'.length) .should('have.prop', 'selectionEnd', '123_456'.length); }); }); describe('minus sign', () => { it('can type minus sign', () => { cy.get('@input') .type('−') .should('have.value', '−') .should('have.prop', 'selectionStart', '−'.length) .should('have.prop', 'selectionEnd', '−'.length); }); it('replaces hyphen with minus sign', () => { cy.get('@input') .type('-') .should('have.value', '−') .should('have.prop', 'selectionStart', '−'.length) .should('have.prop', 'selectionEnd', '−'.length); }); it('replaces en-dash with minus sign', () => { cy.get('@input') .type('–') .should('have.value', '−') .should('have.prop', 'selectionStart', '−'.length) .should('have.prop', 'selectionEnd', '−'.length); }); it('replaces em-dash with minus sign', () => { cy.get('@input') .type('—') .should('have.value', '−') .should('have.prop', 'selectionStart', '−'.length) .should('have.prop', 'selectionEnd', '−'.length); }); it('can type "−111" (minus)', () => { cy.get('@input') .type('−111') .should('have.value', '−111') .should('have.prop', 'selectionStart', '−111'.length) .should('have.prop', 'selectionEnd', '−111'.length); }); it('can type "-3333" (hyphen)', () => { cy.get('@input') .type('-3333') .should('have.value', '−3_333') .should('have.prop', 'selectionStart', '−3_333'.length) .should('have.prop', 'selectionEnd', '−3_333'.length); }); it('can type "–123.45" (en-dash)', () => { cy.get('@input') .type('–123.45') .should('have.value', '−123.45') .should('have.prop', 'selectionStart', '−123.45'.length) .should('have.prop', 'selectionEnd', '−123.45'.length); }); it('can type "—0,12" (em-dash)', () => { cy.get('@input') .type('—0,12') .should('have.value', '−0.12') .should('have.prop', 'selectionStart', '−0.12'.length) .should('have.prop', 'selectionEnd', '−0.12'.length); }); }); it('"Backspace"-key does nothing when cursor at the START of element', () => { cy.get('@input') .type('111') .type('{moveToStart}') .type('{backspace}') .should('have.value', '111') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0) .type('{backspace}'.repeat(4)) .should('have.value', '111') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('"Delete"-key does nothing when cursor at the END of element', () => { cy.get('@input') .type('111') .type('{moveToEnd}') .type('{del}') .should('have.value', '111') .should('have.prop', 'selectionStart', '111'.length) .should('have.prop', 'selectionEnd', '111'.length) .type('{del}'.repeat(4)) .should('have.value', '111') .should('have.prop', 'selectionStart', '111'.length) .should('have.prop', 'selectionEnd', '111'.length); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/number/number-bigint.cy.ts ================================================ import {CHAR_MINUS} from 'projects/kit/src/lib/constants'; import {openNumberPage} from './utils'; describe('Number | BigInt', () => { describe('huge integers', () => { beforeEach(() => { openNumberPage(); }); const expectedValue = '900 719 925 474 099 190 071 992 547 409 919 007 199 254 740 991'; it('types String(Number.MAX_SAFE_INTEGER).repeat(3)', () => { cy.get('@input') .type(String(Number.MAX_SAFE_INTEGER).repeat(3)) .should('have.value', expectedValue) .should('have.prop', 'selectionStart', expectedValue.length) .should('have.prop', 'selectionEnd', expectedValue.length); }); it('types minus + String(Number.MAX_SAFE_INTEGER).repeat(3)', () => { cy.get('@input') .type(`-${String(Number.MAX_SAFE_INTEGER).repeat(3)}`) .should('have.value', `${CHAR_MINUS}${expectedValue}`) .should('have.prop', 'selectionStart', 1 + expectedValue.length) .should('have.prop', 'selectionEnd', 1 + expectedValue.length); }); }); describe('huge decimals', () => { beforeEach(() => { openNumberPage('maximumFractionDigits=Infinity&thousandSeparator=_'); }); it('types String(Number.MAX_SAFE_INTEGER).repeat(2) + ".123456789"', () => { const expectedValue = '90_071_992_547_409_919_007_199_254_740_991.123456789'; cy.get('@input') .type(`${String(Number.MAX_SAFE_INTEGER).repeat(2)},123456789`) .should('have.value', expectedValue) .should('have.prop', 'selectionStart', expectedValue.length) .should('have.prop', 'selectionEnd', expectedValue.length); }); it('types minus + String(Number.MAX_SAFE_INTEGER).repeat(2) + ".123456789"', () => { const expectedValue = `${CHAR_MINUS}90_071_992_547_409_919_007_199_254_740_991.123456789`; cy.get('@input') .type(`-${String(Number.MAX_SAFE_INTEGER).repeat(2)}.123456789`) .should('have.value', expectedValue) .should('have.prop', 'selectionStart', expectedValue.length) .should('have.prop', 'selectionEnd', expectedValue.length); }); it('types pseudo decimal separator + decimal part String(Number.MAX_SAFE_INTEGER).repeat(3)', () => { const expectedValue = '0.900719925474099190071992547409919007199254740991'; cy.get('@input') .type(`,${String(Number.MAX_SAFE_INTEGER).repeat(3)}`) .should('have.value', expectedValue) .should('have.prop', 'selectionStart', expectedValue.length) .should('have.prop', 'selectionEnd', expectedValue.length); }); }); describe('affixes', () => { it('types String(Number.MAX_SAFE_INTEGER).repeat(3) with prefix=$', () => { openNumberPage('prefix=$&thousandSeparator=.'); const expectedValue = '$900.719.925.474.099.190.071.992.547.409.919.007.199.254.740.991'; cy.get('@input') .type(String(Number.MAX_SAFE_INTEGER).repeat(3)) .should('have.value', expectedValue) .should('have.prop', 'selectionStart', expectedValue.length) .should('have.prop', 'selectionEnd', expectedValue.length); }); it('types String(Number.MAX_SAFE_INTEGER).repeat(3) with postfix=%', () => { openNumberPage(`postfix=${encodeURIComponent('%')}&thousandSeparator=,`); const expectedValue = `${CHAR_MINUS}900,719,925,474,099,190,071,992,547,409,919,007,199,254,740,991%`; cy.get('@input') .type(`${CHAR_MINUS}${String(Number.MAX_SAFE_INTEGER).repeat(3)}`) .should('have.value', expectedValue) .should('have.prop', 'selectionStart', expectedValue.length - 1) .should('have.prop', 'selectionEnd', expectedValue.length - 1); }); it('types hyphen + String(Number.MAX_SAFE_INTEGER).repeat(3) when prefix="$" & negativePattern="minusFirst"', () => { openNumberPage('prefix=$&negativePattern=minusFirst&&thousandSeparator=_'); const expectedValue = `${CHAR_MINUS}$900_719_925_474_099_190_071_992_547_409_919_007_199_254_740_991`; cy.get('@input') .type(`-${String(Number.MAX_SAFE_INTEGER).repeat(3)}`) .should('have.value', expectedValue) .should('have.prop', 'selectionStart', expectedValue.length) .should('have.prop', 'selectionEnd', expectedValue.length); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/number/number-decimal-separator.cy.ts ================================================ import {BROWSER_SUPPORTS_REAL_EVENTS} from '../../../support/constants'; import {repeatKey} from '../../utils'; import {openNumberPage} from './utils'; describe('Number | Decimal separator (symbol used to separate the integer part from the fractional part)', () => { describe('Decimal separator is a comma (default)', () => { beforeEach(() => { openNumberPage( 'decimalSeparator=,&thousandSeparator=_&maximumFractionDigits=2', ); }); it('accepts comma (as the last character)', () => { cy.get('@input') .type('123,') .should('have.value', '123,') .should('have.prop', 'selectionStart', '123,'.length) .should('have.prop', 'selectionEnd', '123,'.length); }); it('accepts comma (in the middle)', () => { cy.get('@input') .type('42') .type('{leftArrow}') .type(',') .should('have.value', '4,2') .should('have.prop', 'selectionStart', '4,'.length) .should('have.prop', 'selectionEnd', '4,'.length); }); it('accepts dot (as the last character) and transforms it to comma', () => { cy.get('@input') .type('123.') .should('have.value', '123,') .should('have.prop', 'selectionStart', '123,'.length) .should('have.prop', 'selectionEnd', '123,'.length); }); it('accepts dot (in the middle) and transforms it to comma', () => { cy.get('@input') .type('42') .type('{leftArrow}') .type('.') .should('have.value', '4,2') .should('have.prop', 'selectionStart', '4,'.length) .should('have.prop', 'selectionEnd', '4,'.length); }); it('accepts "б" (as the last character) and transforms it to comma', () => { cy.get('@input') .type('123б') .should('have.value', '123,') .should('have.prop', 'selectionStart', '123,'.length) .should('have.prop', 'selectionEnd', '123,'.length); }); it('accepts "б" (in the middle) and transforms it to comma', () => { cy.get('@input') .type('42') .type('{leftArrow}') .type('б') .should('have.value', '4,2') .should('have.prop', 'selectionStart', '4,'.length) .should('have.prop', 'selectionEnd', '4,'.length); }); it('accepts "Ю" (as the last character) and transforms it to comma', () => { cy.get('@input') .type('123Ю') .should('have.value', '123,') .should('have.prop', 'selectionStart', '123,'.length) .should('have.prop', 'selectionEnd', '123,'.length); }); it('accepts "Ю" (in the middle) and transforms it to comma', () => { cy.get('@input') .type('42') .type('{leftArrow}') .type('Ю') .should('have.value', '4,2') .should('have.prop', 'selectionStart', '4,'.length) .should('have.prop', 'selectionEnd', '4,'.length); }); }); describe('Decimal separator is a dot', () => { beforeEach(() => { openNumberPage( 'decimalSeparator=.&maximumFractionDigits=2&decimalPseudoSeparators$=2', ); }); it('accepts dot (as the last character)', () => { cy.get('@input') .type('123.') .should('have.value', '123.') .should('have.prop', 'selectionStart', '123.'.length) .should('have.prop', 'selectionEnd', '123.'.length); }); it('accepts dot (in the middle)', () => { cy.get('@input') .type('42') .type('{leftArrow}') .type('.') .should('have.value', '4.2') .should('have.prop', 'selectionStart', '4.'.length) .should('have.prop', 'selectionEnd', '4.'.length); }); it('accepts comma (as the last character) and transforms it to dot', () => { cy.get('@input') .type('123,') .should('have.value', '123.') .should('have.prop', 'selectionStart', '123.'.length) .should('have.prop', 'selectionEnd', '123.'.length); }); it('accepts comma (in the middle) and transforms it to dot', () => { cy.get('@input') .type('42') .type('{leftArrow}') .type(',') .should('have.value', '4.2') .should('have.prop', 'selectionStart', '4.'.length) .should('have.prop', 'selectionEnd', '4.'.length); }); it('rejects invalid characters', () => { cy.get('@input') .type('123') .type('{moveToStart}{rightArrow}') .type('F') .should('have.value', '123'); }); }); describe('Attempt to enter decimal separator when it already exists in text field', () => { beforeEach(() => { openNumberPage( 'decimalSeparator=,&thousandSeparator=_&maximumFractionDigits=2', ); }); it('1|23,45 => Press comma (decimal separator) => 1|23,45 (no changes)', () => { cy.get('@input') .type('123,45') .type('{moveToStart}{rightArrow}') .type(',') .should('have.value', '123,45') .should('have.prop', 'selectionStart', '1'.length) .should('have.prop', 'selectionEnd', '1'.length); }); it('1|23,45 => Press point (pseudo decimal separator) => 1|23,45 (no changes)', () => { cy.get('@input') .type('123.45') .type('{moveToStart}{rightArrow}') .type('.') .should('have.value', '123,45') .should('have.prop', 'selectionStart', '1'.length) .should('have.prop', 'selectionEnd', '1'.length); }); it( '1|23,4|5 => Type decimal separator => 1,5', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('123,45') .realPress([ 'ArrowLeft', 'Shift', ...repeatKey('ArrowLeft', '23,4'.length), ]); cy.get('@input') .type(',') .should('have.value', '1,5') .should('have.prop', 'selectionStart', '1,'.length) .should('have.prop', 'selectionEnd', '1,'.length); }, ); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/number/number-decimal-zero-padding.cy.ts ================================================ import {openNumberPage} from './utils'; describe('Number | decimalZeroPadding', () => { describe('[minimumFractionDigits] === [maximumFractionDigits] === 4', () => { beforeEach(() => { openNumberPage( 'decimalSeparator=,&maximumFractionDigits=4&minimumFractionDigits=4', ); }); it('Type 42 => 42,0000', () => { cy.get('@input') .type('42') .should('have.value', '42,0000') .should('have.prop', 'selectionStart', '42'.length) .should('have.prop', 'selectionEnd', '42'.length); }); it('Type , => 0,0000', () => { cy.get('@input') .type(',') .should('have.value', '0,0000') .should('have.prop', 'selectionStart', '0,'.length) .should('have.prop', 'selectionEnd', '0,'.length); }); it('Type 42,27 => 42,2700', () => { cy.get('@input') .type('42,27') .should('have.value', '42,2700') .should('have.prop', 'selectionStart', '42,27'.length) .should('have.prop', 'selectionEnd', '42,27'.length); }); it('Integer part has `overwriteMode: shift`', () => { cy.get('@input') .type('42,27') .type('{leftArrow}'.repeat('2,27'.length)) .type('55') .should('have.value', '4 552,2700') .should('have.prop', 'selectionStart', '4 55'.length) .should('have.prop', 'selectionEnd', '4 55'.length); }); it('Decimal part has `overwriteMode: replace`', () => { cy.get('@input') .type('42,27') .type('{leftArrow}'.repeat('27'.length)) .type('55') .should('have.value', '42,5500') .should('have.prop', 'selectionStart', '42,55'.length) .should('have.prop', 'selectionEnd', '42,55'.length); }); it('42,|2700 => Backspace => 42|,2700', () => { cy.get('@input') .type('42,27') .type('{leftArrow}'.repeat('27'.length)) .type('{backspace}') .should('have.value', '42,2700') .should('have.prop', 'selectionStart', '42'.length) .should('have.prop', 'selectionEnd', '42'.length); }); it('42|,2700 => Delete => 42,|2700', () => { cy.get('@input') .type('42,27') .type('{leftArrow}'.repeat(',27'.length)) .type('{del}') .should('have.value', '42,2700') .should('have.prop', 'selectionStart', '42,'.length) .should('have.prop', 'selectionEnd', '42,'.length); }); it('0|,4242 => Backspace => |,4242 => ,4242| => Backspace x4 => ,|0000', () => { cy.get('@input') .type('0,4242') .type('{moveToStart}{rightArrow}') .type('{backspace}') .should('have.value', ',4242') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0) .type('{moveToEnd}') .type('{backspace}'.repeat(2)) .should('have.value', ',4200') .should('have.prop', 'selectionStart', ',42'.length) .should('have.prop', 'selectionEnd', ',42'.length) .type('{backspace}'.repeat(2)) .should('have.value', ',0000') .should('have.prop', 'selectionStart', ','.length) .should('have.prop', 'selectionEnd', ','.length); }); describe('Extra decimal separator insertion', () => { it('42,|2700 => Type , => 42,|2700', () => { cy.get('@input') .type('42,27') .type('{leftArrow}'.repeat('27'.length)) .should('have.value', '42,2700') .should('have.prop', 'selectionStart', '42,'.length) .should('have.prop', 'selectionEnd', '42,'.length) .type(',') .should('have.value', '42,2700') .should('have.prop', 'selectionStart', '42,'.length) .should('have.prop', 'selectionEnd', '42,'.length); }); it('42|,2700 => Type , => 42,|2700', () => { cy.get('@input') .type('42,27') .type('{leftArrow}'.repeat(',27'.length)) .should('have.value', '42,2700') .should('have.prop', 'selectionStart', '42'.length) .should('have.prop', 'selectionEnd', '42'.length) .type(',') .should('have.value', '42,2700') .should('have.prop', 'selectionStart', '42,'.length) .should('have.prop', 'selectionEnd', '42,'.length); }); it('42,2|700 => Type , => 42,2|700', () => { cy.get('@input') .type('42,27') .type('{leftArrow}') .should('have.value', '42,2700') .should('have.prop', 'selectionStart', '42,2'.length) .should('have.prop', 'selectionEnd', '42,2'.length) .type(',') .should('have.value', '42,2700') .should('have.prop', 'selectionStart', '42,2'.length) .should('have.prop', 'selectionEnd', '42,2'.length); }); it('9|9,1234 => Type , => 9|9,1234 (no changes)', () => { cy.get('@input') .type('99,1234') .type('{moveToStart}{rightArrow}') .should('have.value', '99,1234') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .type(',') .should('have.value', '99,1234') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); }); describe('Move caret when user tries to delete non-removable zeroes in decimal part', () => { beforeEach(() => { cy.get('@input').type(',').should('have.value', '0,0000'); }); describe('Via `Backspace` button', () => { it('0,0000| => Backspace => 0,000|0', () => { cy.get('@input') .type('{moveToEnd}{backspace}') .should('have.value', '0,0000') .should('have.prop', 'selectionStart', '0,000'.length) .should('have.prop', 'selectionEnd', '0,000'.length); }); it('0,000|0 => Backspace => 0,00|00', () => { cy.get('@input') .type('{moveToEnd}{leftArrow}{backspace}') .should('have.value', '0,0000') .should('have.prop', 'selectionStart', '0,00'.length) .should('have.prop', 'selectionEnd', '0,00'.length); }); it('0,00|00 => Backspace => 0,0|000', () => { cy.get('@input') .type('{moveToEnd}') .type('{leftArrow}'.repeat(2)) .type('{backspace}') .should('have.value', '0,0000') .should('have.prop', 'selectionStart', '0,0'.length) .should('have.prop', 'selectionEnd', '0,0'.length); }); it('0,0|000 => Backspace => 0,|0000', () => { cy.get('@input') .type('{moveToEnd}') .type('{leftArrow}'.repeat(3)) .type('{backspace}') .should('have.value', '0,0000') .should('have.prop', 'selectionStart', '0,'.length) .should('have.prop', 'selectionEnd', '0,'.length); }); }); describe('Via `Delete` button', () => { it('0,|0000 => Delete => 0,0|000', () => { cy.get('@input') .type('{moveToStart}') .type('{rightArrow}'.repeat('0,'.length)) .type('{del}') .should('have.value', '0,0000') .should('have.prop', 'selectionStart', '0,0'.length) .should('have.prop', 'selectionEnd', '0,0'.length); }); it('0,0|000 => Delete => 0,00|00', () => { cy.get('@input') .type('{moveToStart}') .type('{rightArrow}'.repeat('0,0'.length)) .type('{del}') .should('have.value', '0,0000') .should('have.prop', 'selectionStart', '0,00'.length) .should('have.prop', 'selectionEnd', '0,00'.length); }); it('0,00|00 => Delete => 0,000|0', () => { cy.get('@input') .type('{moveToStart}') .type('{rightArrow}'.repeat('0,00'.length)) .type('{del}') .should('have.value', '0,0000') .should('have.prop', 'selectionStart', '0,000'.length) .should('have.prop', 'selectionEnd', '0,000'.length); }); it('0,000|0 => Delete => 0,0000|', () => { cy.get('@input') .type('{moveToStart}') .type('{rightArrow}'.repeat('0,000'.length)) .type('{del}') .should('have.value', '0,0000') .should('have.prop', 'selectionStart', '0,0000'.length) .should('have.prop', 'selectionEnd', '0,0000'.length); }); }); }); }); describe('[minimumFractionDigits] === 3; [maximumFractionDigits] === 5', () => { beforeEach(() => { openNumberPage('minimumFractionDigits=3&maximumFractionDigits=5'); }); it('Type 42 => 42.000', () => { cy.get('@input') .type('42') .should('have.value', '42.000') .should('have.prop', 'selectionStart', '42'.length) .should('have.prop', 'selectionEnd', '42'.length); }); it('Type 42 => 42.000 => Move cursor to the end => Type 12', () => { cy.get('@input') .type('42') .should('have.value', '42.000') .type('{moveToEnd}') .type('45') .should('have.prop', 'selectionStart', '42.00045'.length) .should('have.prop', 'selectionEnd', '42.00045'.length); }); it('Type , => 0.|000', () => { cy.get('@input') .type(',') .should('have.value', '0.000') .should('have.prop', 'selectionStart', '0.'.length) .should('have.prop', 'selectionEnd', '0.'.length); }); it('Type 42.27 => 42.270', () => { cy.get('@input') .type('42.27') .should('have.value', '42.270') .should('have.prop', 'selectionStart', '42.27'.length) .should('have.prop', 'selectionEnd', '42.27'.length); }); it('Integer part has `overwriteMode: shift`', () => { cy.get('@input') .type('42,27') .type('{leftArrow}'.repeat('2.27'.length)) .type('55') .should('have.value', '4 552.270') .should('have.prop', 'selectionStart', '4 55'.length) .should('have.prop', 'selectionEnd', '4 55'.length); }); it('Decimal part has `overwriteMode: replace`', () => { cy.get('@input') .type('42,27') .type('{leftArrow}'.repeat('27'.length)) .type('55') .should('have.value', '42.550') .should('have.prop', 'selectionStart', '42.55'.length) .should('have.prop', 'selectionEnd', '42.55'.length); }); it('42.|270 => Backspace => 42|.270', () => { cy.get('@input') .type('42.27') .type('{leftArrow}'.repeat('27'.length)) .type('{backspace}') .should('have.value', '42.270') .should('have.prop', 'selectionStart', '42'.length) .should('have.prop', 'selectionEnd', '42'.length); }); it('42|.270 => Delete => 42.|270', () => { cy.get('@input') .type('42,27') .type('{leftArrow}'.repeat('.27'.length)) .type('{del}') .should('have.value', '42.270') .should('have.prop', 'selectionStart', '42.'.length) .should('have.prop', 'selectionEnd', '42.'.length); }); it('0|.4242 => Backspace => |.4242 => .4242| => Backspace x4 => .|000', () => { cy.get('@input') .type('0.4242') .type('{moveToStart}{rightArrow}') .type('{backspace}') .should('have.value', '.4242') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0) .type('{moveToEnd}') .type('{backspace}'.repeat(2)) .should('have.value', '.420') .should('have.prop', 'selectionStart', '.42'.length) .should('have.prop', 'selectionEnd', '.42'.length) .type('{backspace}'.repeat(2)) .should('have.value', '.000') .should('have.prop', 'selectionStart', '.'.length) .should('have.prop', 'selectionEnd', '.'.length); }); describe('Extra decimal separator insertion', () => { it('42.|270 => Type . => 42.|270', () => { cy.get('@input') .type('42.27') .type('{leftArrow}'.repeat('27'.length)) .should('have.value', '42.270') .should('have.prop', 'selectionStart', '42.'.length) .should('have.prop', 'selectionEnd', '42.'.length) .type('.') .should('have.value', '42.270') .should('have.prop', 'selectionStart', '42.'.length) .should('have.prop', 'selectionEnd', '42.'.length); }); it('42|.270 => Type . => 42.|270', () => { cy.get('@input') .type('42.27') .type('{leftArrow}'.repeat('.27'.length)) .should('have.value', '42.270') .should('have.prop', 'selectionStart', '42'.length) .should('have.prop', 'selectionEnd', '42'.length) .type('.') .should('have.value', '42.270') .should('have.prop', 'selectionStart', '42.'.length) .should('have.prop', 'selectionEnd', '42.'.length); }); it('42.2|70 => Type , => 42.2|70', () => { cy.get('@input') .type('42.27') .type('{leftArrow}') .should('have.value', '42.270') .should('have.prop', 'selectionStart', '42.2'.length) .should('have.prop', 'selectionEnd', '42.2'.length) .type(',') .should('have.value', '42.270') .should('have.prop', 'selectionStart', '42.2'.length) .should('have.prop', 'selectionEnd', '42.2'.length); }); it('9|9.1234 => Type . => 9|9.1234 (no changes)', () => { cy.get('@input') .type('99.1234') .type('{moveToStart}{rightArrow}') .should('have.value', '99.1234') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .type('.') .should('have.value', '99.1234') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); }); describe('Move caret when user tries to delete non-removable zeroes in decimal part', () => { beforeEach(() => { cy.get('@input').type(',').should('have.value', '0.000'); }); describe('Via `Backspace` button', () => { it('0.000| => Backspace => 0.00|0', () => { cy.get('@input') .type('{moveToEnd}{backspace}') .should('have.value', '0.000') .should('have.prop', 'selectionStart', '0.00'.length) .should('have.prop', 'selectionEnd', '0.00'.length); }); it('0.00|0 => Backspace => 0.0|00', () => { cy.get('@input') .type('{moveToEnd}{leftArrow}{backspace}') .should('have.value', '0.000') .should('have.prop', 'selectionStart', '0.0'.length) .should('have.prop', 'selectionEnd', '0.0'.length); }); it('0.0|00 => Backspace => 0.|000', () => { cy.get('@input') .type('{moveToEnd}') .type('{leftArrow}'.repeat(2)) .type('{backspace}') .should('have.value', '0.000') .should('have.prop', 'selectionStart', '0.'.length) .should('have.prop', 'selectionEnd', '0.'.length); }); }); describe('Via `Delete` button', () => { it('0.|000 => Delete => 0.0|00', () => { cy.get('@input') .type('{moveToStart}') .type('{rightArrow}'.repeat('0.'.length)) .type('{del}') .should('have.value', '0.000') .should('have.prop', 'selectionStart', '0.0'.length) .should('have.prop', 'selectionEnd', '0.0'.length); }); it('0.0|00 => Delete => 0.00|0', () => { cy.get('@input') .type('{moveToStart}') .type('{rightArrow}'.repeat('0.0'.length)) .type('{del}') .should('have.value', '0.000') .should('have.prop', 'selectionStart', '0.00'.length) .should('have.prop', 'selectionEnd', '0.00'.length); }); it('0.00|0 => Delete => 0.000|', () => { cy.get('@input') .type('{moveToStart}') .type('{rightArrow}'.repeat('0.00'.length)) .type('{del}') .should('have.value', '0.000') .should('have.prop', 'selectionStart', '0.000'.length) .should('have.prop', 'selectionEnd', '0.000'.length); }); }); }); }); describe('[decimalZeroPadding] is compatible with [postfix]', () => { it('Type 42', () => { openNumberPage( 'prefix=$&postfix=$&minimumFractionDigits=2&maximumFractionDigits=2', ); cy.get('@input') .type('42') .should('have.value', '$42.00$') .should('have.prop', 'selectionStart', '$42'.length) .should('have.prop', 'selectionEnd', '$42'.length); }); it('Type 42,24', () => { openNumberPage( 'prefix=$&postfix=$&minimumFractionDigits=2&maximumFractionDigits=2', ); cy.get('@input') .type('42,24') .should('have.value', '$42.24$') .should('have.prop', 'selectionStart', '$42.24'.length) .should('have.prop', 'selectionEnd', '$42.24'.length); }); it('Type 42.24', () => { openNumberPage( 'prefix=$&postfix=kg&minimumFractionDigits=2&maximumFractionDigits=2', ); cy.get('@input') .type('42.24') .should('have.value', '$42.24kg') .should('have.prop', 'selectionStart', '$42.24'.length) .should('have.prop', 'selectionEnd', '$42.24'.length); }); }); describe('conditions for empty textfield', () => { beforeEach(() => { openNumberPage( 'thousandSeparator=_&maximumFractionDigits=2&minimumFractionDigits=2&minusSign=-', ); }); it('0.42| => Backspace x3 => 0|.00 => Backspace => Empty', () => { cy.get('@input') .type('0.42') .should('have.value', '0.42') .type('{backspace}'.repeat(3)) .should('have.value', '0.00') .should('have.a.prop', 'selectionStart', 1) .should('have.a.prop', 'selectionEnd', 1) .type('{backspace}') .should('have.value', ''); }); it('-.42| => Backspace x2 => -.|00 => Backspace => -', () => { cy.get('@input') .type('-0.42') .should('have.value', '-0.42') .type('{moveToStart}') .type('{rightArrow}'.repeat(2)) .type('{backspace}') .should('have.value', '-.42') .type('{moveToEnd}') .type('{backspace}') .should('have.value', '-.40') .type('{backspace}') .should('have.value', '-.00') .should('have.a.prop', 'selectionStart', 2) .should('have.a.prop', 'selectionEnd', 2) .type('{backspace}') .should('have.value', '-') .should('have.a.prop', 'selectionStart', 1) .should('have.a.prop', 'selectionEnd', 1); }); it('5|.00 => Backspace => Empty', () => { cy.get('@input') .type('5') .should('have.value', '5.00') .should('have.a.prop', 'selectionStart', 1) .should('have.a.prop', 'selectionEnd', 1) .type('{backspace}') .should('have.value', ''); }); it('-5|.00 => Backspace => -', () => { cy.get('@input') .type('-5') .should('have.value', '-5.00') .should('have.a.prop', 'selectionStart', 2) .should('have.a.prop', 'selectionEnd', 2) .type('{backspace}') .should('have.value', '-'); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/number/number-examples.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Documentation page "Number"', () => { beforeEach(() => { cy.visit(DemoPath.Number); }); describe('Example "Postfix"', () => { beforeEach(() => { cy.get('#postfix input').should('be.visible').first().as('input'); }); it('pads value without digits with zero on blur', () => { cy.get('@input') .focus() .should('have.prop', 'selectionStart', '97'.length) .should('have.prop', 'selectionEnd', '97'.length) .clear() .should('have.value', '%') .blur() .should('have.value', '0%'); }); }); describe('Example "Thousand separator pattern" (Japanese yen, manual)', () => { beforeEach(() => { cy.get('#thousand-separator-pattern input') .should('be.visible') .first() .focus() .clear() .as('input'); }); it('groups 8 digits in sets of 4: ¥1234,5678', () => { cy.get('@input').type('12345678').should('have.value', '¥1234,5678'); }); it('groups 9 digits in sets of 4: ¥1,2345,6789', () => { cy.get('@input').type('123456789').should('have.value', '¥1,2345,6789'); }); it('regroups after backspace: ¥1,2345,6789 => ¥1234,5678', () => { cy.get('@input') .type('123456789') .type('{backspace}') .should('have.value', '¥1234,5678'); }); }); describe('Example "Thousand separator pattern uses Intl.NumberFormat" (Indian, via Intl)', () => { beforeEach(() => { cy.get('#thousand-separator-pattern-intl input') .should('be.visible') .first() .focus() .clear() .as('input'); }); it('groups 7 digits as 2+2+3 from right: ₹12,34,567', () => { cy.get('@input').type('1234567').should('have.value', '₹12,34,567'); }); it('regroups after backspace: ₹12,34,567 => ₹1,23,456', () => { cy.get('@input') .type('1234567') .type('{backspace}') .should('have.value', '₹1,23,456'); }); it('groups 9 digits as 3+2+2+2 from right: ₹12,34,56,789', () => { cy.get('@input').type('123456789').should('have.value', '₹12,34,56,789'); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/number/number-fullwidth-to-halfwidth.cy.ts ================================================ import {openNumberPage} from './utils'; describe('Number | Accepts full width numbers used by JP, CN or others', () => { beforeEach(() => { openNumberPage('thousandSeparator=_&maximumFractionDigits=2'); }); describe('Invalid characters', () => { it('accepts full width numbers', () => { cy.get('@input') .type('1 2 3 4 5') .should('have.value', '12_345') .should('have.prop', 'selectionStart', '12_345'.length) .should('have.prop', 'selectionEnd', '12_345'.length); }); it('accepts full width characters with minus', () => { cy.get('@input') .type('ー123456') .should('have.value', '−123_456') .should('have.prop', 'selectionStart', '−123_456'.length) .should('have.prop', 'selectionEnd', '−123_456'.length); }); it('rejects full width characters, not numbers', () => { cy.get('@input') .type('あいうえお12345こんにちは') .should('have.value', '12_345') .should('have.prop', 'selectionStart', '12_345'.length) .should('have.prop', 'selectionEnd', '12_345'.length); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/number/number-max-validation.cy.ts ================================================ import {CHAR_MINUS} from 'projects/kit/src/lib/constants'; import {openNumberPage} from './utils'; describe('Number | Max validation', () => { describe('Max = 3', () => { beforeEach(() => { openNumberPage('max=3&maximumFractionDigits=4'); }); ['0', '1', '2', '3'].forEach((value) => { it(`accepts ${value}`, () => { cy.get('@input') .type(value) .should('have.value', value) .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); }); ['4', '5', '6', '7', '8', '9'].forEach((value) => { it(`rejects ${value} (replace it with max value)`, () => { cy.get('@input') .type(value) .should('have.value', '3') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); }); describe('accepts any decimal value (integer part is less than max one)', () => { it('0,9999', () => { cy.get('@input') .type(',9999') .should('have.value', '0.9999') .should('have.prop', 'selectionStart', '0.9999'.length) .should('have.prop', 'selectionEnd', '0.9999'.length); }); it('2,777', () => { cy.get('@input') .type('2,777') .should('have.value', '2.777') .should('have.prop', 'selectionStart', '2.777'.length) .should('have.prop', 'selectionEnd', '2.777'.length); }); }); it('rejects decimal part is integer part is already equal to max', () => { cy.get('@input') .type('3,9') .should('have.value', '3') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('|0 => Type 5 => 3|', () => { cy.get('@input') .type('0') .type('{moveToStart}') .should('have.value', '0') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0) .type('5') .should('have.value', '3') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); }); describe('Max=777', () => { beforeEach(() => { openNumberPage('max=777'); }); ['5', '10', '77', '770', '776', '777'].forEach((value) => { it(`accepts ${value}`, () => { cy.get('@input') .type(value) .should('have.value', value) .should('have.prop', 'selectionStart', value.length) .should('have.prop', 'selectionEnd', value.length); }); }); ['778', '779', '7777', '1000'].forEach((value) => { it(`rejects ${value} (replace it with max value)`, () => { cy.get('@input') .type(value) .should('have.value', '777') .should('have.prop', 'selectionStart', 3) .should('have.prop', 'selectionEnd', 3); }); }); it('9|9 => Type 7 => 777|', () => { cy.get('@input') .type('99') .type('{leftArrow}') .should('have.value', '99') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .type('0') .should('have.value', '777') .should('have.prop', 'selectionStart', '777'.length) .should('have.prop', 'selectionEnd', '777'.length); }); }); describe('Max = -5', () => { beforeEach(() => { openNumberPage('max=-5'); }); it('can type -42 (via keyboard, 1 character per keydown)', () => { cy.get('@input') .type('-4') .should('have.value', '−4') .should('have.prop', 'selectionStart', '−4'.length) .should('have.prop', 'selectionEnd', '−4'.length) .type('2') .should('have.value', '−42') .should('have.prop', 'selectionStart', '−42'.length) .should('have.prop', 'selectionEnd', '−42'.length); }); it('replaces -4 with -5 on blur', () => { cy.get('@input') .type('-4') .wait(100) // to be sure that value is not changed even in case of some async validation .should('have.value', '−4') .should('have.prop', 'selectionStart', '−4'.length) .should('have.prop', 'selectionEnd', '−4'.length) .blur() .should('have.value', '−5'); }); it('keeps -6 untouched on blur', () => { cy.get('@input') .type('-6') .wait(100) // to be sure that value is not changed even in case of some async validation .should('have.value', '−6') .should('have.prop', 'selectionStart', '−6'.length) .should('have.prop', 'selectionEnd', '−6'.length) .blur() .wait(100) .should('have.value', '−6'); }); }); describe('postfix with digit (cm3) should not affect max validation', () => { it(`types 1234567890123456 with max=${Number.MAX_SAFE_INTEGER} — NOT clamped to max`, () => { openNumberPage(`postfix=cm3&max=${Number.MAX_SAFE_INTEGER}`); const expectedValue = '1 234 567 890 123 456cm3'; cy.get('@input') .type('1234567890123456') .should('have.value', expectedValue) .should( 'have.prop', 'selectionStart', expectedValue.length - 'cm3'.length, ) .should('have.prop', 'selectionEnd', expectedValue.length - 'cm3'.length); }); it('types value exceeding max — IS clamped to 999', () => { openNumberPage('postfix=cm3&max=999'); cy.get('@input') .type('12345') .should('have.value', '999cm3') .should('have.prop', 'selectionStart', '999'.length) .should('have.prop', 'selectionEnd', '999'.length); }); it(`types negative value within min with min=${Number.MIN_SAFE_INTEGER} — NOT clamped`, () => { openNumberPage(`postfix=cm3&min=${Number.MIN_SAFE_INTEGER}`); const expectedValue = `${CHAR_MINUS}1 234 567 890 123 456cm3`; cy.get('@input') .type('-1234567890123456') .should('have.value', expectedValue) .should( 'have.prop', 'selectionStart', expectedValue.length - 'cm3'.length, ) .should('have.prop', 'selectionEnd', expectedValue.length - 'cm3'.length); }); }); describe('prefix with digit (100x) should not affect max validation', () => { it('types value within max — NOT clamped', () => { openNumberPage('prefix=100x&max=999'); const expectedValue = '100x500'; cy.get('@input') .type('500') .should('have.value', expectedValue) .should('have.prop', 'selectionStart', expectedValue.length) .should('have.prop', 'selectionEnd', expectedValue.length); }); it('types value exceeding max — IS clamped to 999', () => { openNumberPage('prefix=100x&max=999'); cy.get('@input') .type('1234') .should('have.value', '100x999') .should('have.prop', 'selectionStart', '100x999'.length) .should('have.prop', 'selectionEnd', '100x999'.length); }); }); describe('Max = -0.1', () => { beforeEach(() => { openNumberPage('maximumFractionDigits=2&minusSign=-&max=-0.1'); }); it('can type -0.5 (via keyboard, 1 character per keydown)', () => { cy.get('@input') .type('-.') .should('have.value', '-0.') .should('have.prop', 'selectionStart', '-0.'.length) .should('have.prop', 'selectionEnd', '-0.'.length) .type('5') .should('have.value', '-0.5') .should('have.prop', 'selectionStart', '-0.5'.length) .should('have.prop', 'selectionEnd', '-0.5'.length); }); it('keeps -0.10 untouched on blur', () => { cy.get('@input') .type('-0.10') .should('have.value', '-0.10') .blur() .should('have.value', '-0.10'); }); it('replaces 0 with -0.1 on blur', () => { cy.get('@input') .type('0') .should('have.value', '0') .blur() .should('have.value', '-0.1'); }); it('replaces 0.05 with -0.1 on blur', () => { cy.get('@input') .type('-0.05') .should('have.value', '-0.05') .blur() .should('have.value', '-0.1'); }); it('allows to erase the last digit (even if new possible value is more than max) (-0.1| => Backspace => -0.|)', () => { cy.get('@input') .type('-0.1') .should('have.value', '-0.1') .should('have.prop', 'selectionStart', '-0.1'.length) .should('have.prop', 'selectionEnd', '-0.1'.length) .type('{backspace}') .should('have.value', '-0.') .type('{backspace}') .should('have.value', '-0') .type('{backspace}') .should('have.value', '-') .type('{backspace}') .should('have.value', ''); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/number/number-min-validation.cy.ts ================================================ import {openNumberPage} from './utils'; describe('Number | Min validation', () => { describe('Min = -100', () => { beforeEach(() => { openNumberPage('min=-100&maximumFractionDigits=4'); }); ['−1', '−10', '−42', '−100', '0', '1', '5', '99'].forEach((value) => { it(`accepts ${value}`, () => { cy.get('@input') .type(value) .should('have.value', value) .should('have.prop', 'selectionStart', value.length) .should('have.prop', 'selectionEnd', value.length); }); }); ['-101', '-256', '-512'].forEach((value) => { it(`rejects ${value} (replace it with min value)`, () => { cy.get('@input') .type(value) .should('have.value', '−100') .should('have.prop', 'selectionStart', '−100'.length) .should('have.prop', 'selectionEnd', '−100'.length); }); }); describe('accepts any decimal value (integer part is less than max one)', () => { it('-99,9999', () => { cy.get('@input') .type('−99,9999') .should('have.value', '−99.9999') .should('have.prop', 'selectionStart', '−99.9999'.length) .should('have.prop', 'selectionEnd', '−99.9999'.length); }); it('-0,0500', () => { cy.get('@input') .type('-0,0500') .should('have.value', '−0.0500') .should('have.prop', 'selectionStart', '−0.0500'.length) .should('have.prop', 'selectionEnd', '−0.0500'.length); }); }); it('rejects decimal part is integer part is already equal to max', () => { cy.get('@input') .type('-100,0001') .should('have.value', '−100') .should('have.prop', 'selectionStart', '−100'.length) .should('have.prop', 'selectionEnd', '−100'.length); }); it('-|50 => Type 1 => -100|', () => { cy.get('@input') .type('-50') .type('{moveToStart}{rightArrow}') .should('have.value', '−50') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .type('1') .should('have.value', '−100') .should('have.prop', 'selectionStart', '−100'.length) .should('have.prop', 'selectionEnd', '−100'.length); }); }); describe('Min = 5', () => { beforeEach(() => { openNumberPage('min=5&maximumFractionDigits=4'); }); it('can type 10 (via keyboard, 1 character per keydown)', () => { cy.get('@input') .type('1') .should('have.value', '1') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .type('0') .should('have.value', '10') .should('have.prop', 'selectionStart', '10'.length) .should('have.prop', 'selectionEnd', '10'.length); }); it('replaces 1 with 5 on blur', () => { cy.get('@input') .type('1') .wait(100) // to be sure that value is not changed even in case of some async validation .should('have.value', '1') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .blur() .should('have.value', '5'); }); it('keeps 6 untouched on blur', () => { cy.get('@input') .type('6') .wait(100) // to be sure that value is not changed even in case of some async validation .should('have.value', '6') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .blur() .wait(100) .should('have.value', '6'); }); }); describe('Min = 0.1', () => { beforeEach(() => { openNumberPage('min=0.1&maximumFractionDigits=2'); }); it('can type 0.5 (via keyboard, 1 character per keydown)', () => { cy.get('@input') .type('.') .should('have.value', '0.') .should('have.prop', 'selectionStart', '0.'.length) .should('have.prop', 'selectionEnd', '0.'.length) .type('5') .should('have.value', '0.5') .should('have.prop', 'selectionStart', '0.5'.length) .should('have.prop', 'selectionEnd', '0.5'.length); }); it('keeps 0.10 untouched on blur', () => { cy.get('@input') .type('0.10') .should('have.value', '0.10') .blur() .should('have.value', '0.10'); }); it('replaces 0 with 0.1 on blur', () => { cy.get('@input') .type('0') .should('have.value', '0') .blur() .should('have.value', '0.1'); }); it('replaces 0.05 with 0.1 on blur', () => { cy.get('@input') .type('0.05') .should('have.value', '0.05') .blur() .should('have.value', '0.1'); }); it('allows to erase the last digit (even if new possible value is less than min) (0.1| => Backspace => 0.|)', () => { cy.get('@input') .type('0.1') .should('have.value', '0.1') .should('have.prop', 'selectionStart', '0.1'.length) .should('have.prop', 'selectionEnd', '0.1'.length) .type('{backspace}') .should('have.value', '0.') .type('{backspace}') .should('have.value', '0') .type('{backspace}') .should('have.value', ''); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/number/number-minus-before-prefix.cy.ts ================================================ import {CHAR_EM_DASH, CHAR_EN_DASH, CHAR_JP_HYPHEN} from 'projects/kit/src/lib/constants'; import {openNumberPage} from './utils'; describe('Number | Minus before prefix', () => { describe('[prefix]="$" & [minusSign]="-" & negativePattern="minusFirst"', () => { beforeEach(() => { openNumberPage( 'decimalSeparator=.&thousandSeparator=_&maximumFractionDigits=2&minusSign=-&prefix=$&negativePattern=minusFirst', ); }); describe('adds minus even if caret is already placed after prefix', () => { it('works with set minus sign', () => { cy.get('@input') .focus() .should('have.value', '$') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .type('-') .should('have.value', '-$') .should('have.prop', 'selectionStart', '-$'.length) .should('have.prop', 'selectionEnd', '-$'.length); }); it('$|100 => type "-" => -$|100 (caret stays before digits)', () => { cy.get('@input') .type('100') .should('have.value', '$100') .type('{moveToStart}') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .type('-') .should('have.value', '-$100') .should('have.prop', 'selectionStart', '-$'.length) .should('have.prop', 'selectionEnd', '-$'.length); }); describe('works with pseudo minuses', () => { [CHAR_EN_DASH, CHAR_EM_DASH, CHAR_JP_HYPHEN].forEach((minus) => { it(minus, () => { cy.get('@input') .focus() .should('have.value', '$') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .type(minus) .should('have.value', '-$') .should('have.prop', 'selectionStart', '-$'.length) .should('have.prop', 'selectionEnd', '-$'.length); }); }); }); }); describe('thousand separators correctly works with leading minus + prefix', () => { it('Type 123456789', () => { cy.get('@input') .type('-123456789') .should('have.value', '-$123_456_789') .should('have.prop', 'selectionStart', '-$123_456_789'.length) .should('have.prop', 'selectionEnd', '-$123_456_789'.length); }); it('$1_000_000| per day => Backspace => Backspace x2', () => { cy.get('@input') .type('-123456789') .type('{backspace}') .should('have.value', '-$12_345_678') .should('have.prop', 'selectionStart', '-$12_345_678'.length) .should('have.prop', 'selectionEnd', '-$12_345_678'.length) .type('{backspace}'.repeat(2)) .should('have.value', '-$123_456') .should('have.prop', 'selectionStart', '-$123_456'.length) .should('have.prop', 'selectionEnd', '-$123_456'.length) .type('{backspace}'.repeat(3)) .should('have.value', '-$123') .should('have.prop', 'selectionStart', '-$123'.length) .should('have.prop', 'selectionEnd', '-$123'.length); }); it('-$|1_234 => Del => -$|234', () => { cy.get('@input') .type('-1234') .should('have.value', '-$1_234') .should('have.prop', 'selectionStart', '-$1_234'.length) .should('have.prop', 'selectionEnd', '-$1_234'.length) .type('{moveToStart}{del}') .should('have.value', '-$234') .should('have.prop', 'selectionStart', '-$'.length) .should('have.prop', 'selectionEnd', '-$'.length); }); it('$1_2|34 => Backspace => $|234', () => { cy.get('@input') .type('-1234') .should('have.value', '-$1_234') .should('have.prop', 'selectionStart', '-$1_234'.length) .should('have.prop', 'selectionEnd', '-$1_234'.length) .type('{leftArrow}'.repeat(2)) .type('{backspace}') .should('have.value', '-$134') .should('have.prop', 'selectionStart', '-$1'.length) .should('have.prop', 'selectionEnd', '-$1'.length); }); }); it('pads integer part with zero if user types decimal separator (for empty input)', () => { cy.get('@input') .type('-.') .should('have.value', '-$0.') .should('have.prop', 'selectionStart', '-$0.'.length) .should('have.prop', 'selectionEnd', '-$0.'.length) .type('42') .should('have.value', '-$0.42') .should('have.prop', 'selectionStart', '-$0.42'.length) .should('have.prop', 'selectionEnd', '-$0.42'.length); }); it('[maximumFractionDigits] works', () => { cy.get('@input') .type('-.12345678') .should('have.value', '-$0.12') .should('have.prop', 'selectionStart', '-$0.12'.length) .should('have.prop', 'selectionEnd', '-$0.12'.length); }); describe('it removes repeated leading zeroes for integer part on blur', () => { it('Type -000005 => blur => -$5|', () => { cy.get('@input') .type('-000005') .should('have.value', '-$000_005') .should('have.prop', 'selectionStart', '-$000_005'.length) .should('have.prop', 'selectionEnd', '-$000_005'.length) .blur() .should('have.value', '-$5'); }); it('-$0.|05 per day => Backspace => blur => $|5 per day', () => { cy.get('@input') .type('-0.05') .type('{leftArrow}'.repeat('05'.length)) .type('{backspace}') .should('have.value', '-$005') .should('have.prop', 'selectionStart', '-$0'.length) .should('have.prop', 'selectionEnd', '-$0'.length) .blur() .should('have.value', '-$5'); }); }); describe('minus sign is erasable but prefix is non-removable', () => { it('Select all + Backspace', () => { cy.get('@input') .type('-123') .type('{selectAll}{backspace}') .should('have.value', '-$') .should('have.prop', 'selectionStart', 2) .should('have.prop', 'selectionEnd', 2); }); it('-$|42 => Backspace => $|42', () => { cy.get('@input') .type('-42') .type('{moveToStart}') .should('have.value', '-$42') .should('have.prop', 'selectionStart', '-$'.length) .should('have.prop', 'selectionEnd', '-$'.length) .type('{backspace}') .should('have.value', '$42') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .type('{backspace}'.repeat(5)) .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('$|42 => Backspace x5 => $|42', () => { cy.get('@input') .type('42') .type('{moveToStart}') .should('have.value', '$42') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .type('{backspace}'.repeat(5)) .should('have.value', '$42') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('$| => Backspace x5 => $|', () => { cy.get('@input') .focus() .should('have.value', '$') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .type('{backspace}'.repeat(5)) .should('have.value', '$') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('-$| => Backspace => $|', () => { cy.get('@input') .type('-') .should('have.value', '-$') .should('have.prop', 'selectionStart', '-$'.length) .should('have.prop', 'selectionEnd', '-$'.length) .type('{backspace}') .should('have.value', '$') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); }); it('-$|42 => ArrowLeft => -$|42', () => { cy.get('@input') .type('-42') .type('{moveToStart}') .type('{leftArrow}'.repeat(3)) .should('have.value', '-$42') .should('have.prop', 'selectionStart', '-$'.length) .should('have.prop', 'selectionEnd', '-$'.length); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/number/number-minus-sign.cy.ts ================================================ import { CHAR_EM_DASH, CHAR_EN_DASH, CHAR_HYPHEN, CHAR_JP_HYPHEN, CHAR_MINUS, } from 'projects/kit/src/lib/constants'; import {openNumberPage} from './utils'; describe('Number | minus sign', () => { const pseudoMinuses = [ {value: CHAR_HYPHEN, name: 'hyphen'}, {value: CHAR_EN_DASH, name: 'en-dash'}, {value: CHAR_EM_DASH, name: 'em-dash'}, {value: CHAR_JP_HYPHEN, name: 'japanese prolonged sound mark'}, {value: CHAR_MINUS, name: 'unicode minus sign'}, ]; describe('can use hyphen, all kind of dashes and minus interchangeably', () => { const minuses = [ { value: CHAR_HYPHEN, name: 'hyphen', }, { value: CHAR_EN_DASH, name: 'en-dash', }, { value: CHAR_EM_DASH, name: 'em-dash', }, ]; const numbers = ['321', '2_432']; minuses.forEach((minus) => { pseudoMinuses.forEach((pseudoMinus) => { numbers.forEach((number) => { it(`transforms ${pseudoMinus.name} into ${minus.name}`, () => { openNumberPage( `maximumFractionDigits=Infinity&thousandSeparator=_&minusSign=${encodeURIComponent(minus.value)}`, ); cy.get('@input') .type(`${pseudoMinus.value}${number}`) .should('have.value', `${minus.value}${number}`); }); }); }); }); }); describe('can use letters as minus sign', () => { beforeEach(() => { openNumberPage( 'maximumFractionDigits=Infinity&thousandSeparator=_&minusSign=i', ); }); it('transforms i into i', () => { cy.get('@input').type('i1234').should('have.value', 'i1_234'); }); pseudoMinuses.forEach((pseudoMinus) => { it(`transforms ${pseudoMinus.name} into i`, () => { cy.get('@input') .type(`${pseudoMinus.value}1234`) .should('have.value', 'i1_234'); }); }); }); }); describe('custom minus should work properly with min(max) value', () => { [ {value: CHAR_HYPHEN, name: 'hyphen'}, {value: CHAR_EN_DASH, name: 'en-dash'}, {value: CHAR_EM_DASH, name: 'em-dash'}, { value: CHAR_JP_HYPHEN, name: 'japanese prolonged sound mark', }, {value: CHAR_MINUS, name: 'unicode minus sign'}, {value: 'x', name: 'x'}, ].forEach((minus) => { describe(`applies ${minus.name} properly`, () => { beforeEach(() => { openNumberPage( `min=-123&thousandSeparator=_&minusSign=${encodeURIComponent(minus.value)}`, ); }); it(`-94 => ${minus.value}94`, () => { cy.get('@input') .type(`${minus.value}94`) .should('have.value', `${minus.value}94`); }); it(`-432 => ${minus.value}123`, () => { cy.get('@input') .type(`${minus.value}432`) .should('have.value', `${minus.value}123`); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/number/number-precision.cy.ts ================================================ import {openNumberPage} from './utils'; describe('Number | maximumFractionDigits', () => { describe('forbids to type more fractional digits than `maximumFractionDigits` (it is equal to 4)', () => { beforeEach(() => { openNumberPage('decimalSeparator=,&maximumFractionDigits=4'); }); it('Empty input => Type 0,123456789 => 0,1234', () => { cy.get('@input') .type('0,1234') .should('have.value', '0,1234') .should('have.prop', 'selectionStart', '0,1234'.length) .should('have.prop', 'selectionEnd', '0,1234'.length); }); it('Empty input => Type 0,4242000000 => 0,4242', () => { cy.get('@input') .type('0,4242000000') .should('have.value', '0,4242') .should('have.prop', 'selectionStart', '0,4242'.length) .should('have.prop', 'selectionEnd', '0,4242'.length); }); it('Empty input => Type 0,42420000001 => 0,4242', () => { cy.get('@input') .type('0,42420000001') .should('have.value', '0,4242') .should('have.prop', 'selectionStart', '0,4242'.length) .should('have.prop', 'selectionEnd', '0,4242'.length); }); [',', '.', 'б', 'ю'].forEach((separator) => { it(`123|456789 => Type ${separator} => 123,4567`, () => { cy.get('@input') .type('123|456789') .type('{moveToStart}') .type('{rightArrow}'.repeat(3)) .should('have.value', '123 456 789') .should('have.prop', 'selectionStart', '123'.length) .should('have.prop', 'selectionEnd', '123'.length) .type(separator) .should('have.value', '123,4567') .should('have.prop', 'selectionStart', '123,'.length) .should('have.prop', 'selectionEnd', '123,'.length); }); }); }); describe('rejects decimal separator if `maximumFractionDigits` is equal to 0', () => { it('empty input => Type "," => Empty input', () => { openNumberPage('decimalSeparator=,&maximumFractionDigits=0'); cy.get('@input').type(',').should('have.value', ''); }); it('Type "5," => "5"', () => { openNumberPage('decimalSeparator=,&maximumFractionDigits=0'); cy.get('@input') .type('5,') .should('have.value', '5') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); describe('dont rejects thousand separator if it is equal to decimal separator (for maximumFractionDigits=0 value of decimal separator does not matter)', () => { it('simple typing', () => { openNumberPage( 'maximumFractionDigits=0&thousandSeparator=.&decimalSeparator=.', ); cy.get('@input') .type('1234') .should('have.value', '1.234') .should('have.prop', 'selectionStart', '1.234'.length) .should('have.prop', 'selectionEnd', '1.234'.length); }); it('paste from clipboard', () => { openNumberPage( 'maximumFractionDigits=0&thousandSeparator=.&decimalSeparator=.', ); cy.get('@input') .paste('1.234') .should('have.value', '1.234') .should('have.prop', 'selectionStart', '1.234'.length) .should('have.prop', 'selectionEnd', '1.234'.length); }); }); }); describe('keeps untouched decimal part if `maximumFractionDigits: Infinity`', () => { it('0,123456789', () => { openNumberPage('decimalSeparator=,&maximumFractionDigits=Infinity'); cy.get('@input') .type('0,123456789') .should('have.value', '0,123456789') .should('have.prop', 'selectionStart', '0,123456789'.length) .should('have.prop', 'selectionEnd', '0,123456789'.length); }); it('0,0000000001', () => { openNumberPage('decimalSeparator=,&maximumFractionDigits=Infinity'); cy.get('@input') .type('0,0000000001') // 1e-10 .should('have.value', '0,0000000001') .should('have.prop', 'selectionStart', '0,0000000001'.length) .should('have.prop', 'selectionEnd', '0,0000000001'.length) .blur() .wait(100) // to be sure that value is not changed even in case of some async validation .should('have.value', '0,0000000001'); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/number/number-prefix-postfix.cy.ts ================================================ import { CHAR_EM_DASH, CHAR_EN_DASH, CHAR_HYPHEN, CHAR_MINUS, } from 'projects/kit/src/lib/constants'; import {openNumberPage} from './utils'; describe('Number | Prefix & Postfix', () => { describe('[prefix]="$" | [postfix]=" per day"', () => { beforeEach(() => { openNumberPage( 'decimalSeparator=.&thousandSeparator=_&maximumFractionDigits=2&prefix=$', ); cy.get('tr') .contains('[postfix]') .parents('tr') .find('input') .type(' per day'); cy.get('@input') .focus() .should('have.value', '$ per day') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); describe('thousand separators correctly works with prefix', () => { it('Type 1000000', () => { cy.get('@input') .type('1000000') .should('have.value', '$1_000_000 per day') .should('have.prop', 'selectionStart', '$1_000_000'.length) .should('have.prop', 'selectionEnd', '$1_000_000'.length); }); it('$1_000_000| per day => Backspace => Backspace x2', () => { cy.get('@input') .type('1000000') .type('{backspace}') .should('have.value', '$100_000 per day') .should('have.prop', 'selectionStart', '$100_000'.length) .should('have.prop', 'selectionEnd', '$100_000'.length) .type('{backspace}'.repeat(2)) .should('have.value', '$1_000 per day') .should('have.prop', 'selectionStart', '$1_000'.length) .should('have.prop', 'selectionEnd', '$1_000'.length) .type('{backspace}') .should('have.value', '$100 per day') .should('have.prop', 'selectionStart', '$100'.length) .should('have.prop', 'selectionEnd', '$100'.length); }); it('$|1_234 per day => Del => $|234 per day', () => { cy.get('@input') .type('1234') .should('have.value', '$1_234 per day') .should('have.prop', 'selectionStart', '$1_234'.length) .should('have.prop', 'selectionEnd', '$1_234'.length) .type('{moveToStart}{del}') .should('have.value', '$234 per day') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('$1_2|34 per day => Del => $|234 per day', () => { cy.get('@input') .type('1234') .should('have.value', '$1_234 per day') .should('have.prop', 'selectionStart', '$1_234'.length) .should('have.prop', 'selectionEnd', '$1_234'.length) .type('{leftArrow}{leftArrow}{backspace}') .should('have.value', '$134 per day') .should('have.prop', 'selectionStart', '$1'.length) .should('have.prop', 'selectionEnd', '$1'.length); }); }); it('pads integer part with zero if user types decimal separator (for empty input)', () => { cy.get('@input') .type('.45') .should('have.value', '$0.45 per day') .should('have.prop', 'selectionStart', '$0.45'.length) .should('have.prop', 'selectionEnd', '$0.45'.length); }); it('[maximumFractionDigits] works', () => { cy.get('@input') .type('.12345678') .should('have.value', '$0.12 per day') .should('have.prop', 'selectionStart', '$0.12'.length) .should('have.prop', 'selectionEnd', '$0.12'.length); }); describe('it removes repeated leading zeroes for integer part on blur', () => { it('Type 000000 => blur => $0| per day', () => { cy.get('@input') .type('000000') .should('have.value', '$000_000 per day') .should('have.prop', 'selectionStart', '$000_000'.length) .should('have.prop', 'selectionEnd', '$000_000'.length) .blur() .should('have.value', '$0 per day'); }); it('$0| per day => Type 5 => blur => $5| per day', () => { cy.get('@input') .type('0') .should('have.value', '$0 per day') .should('have.prop', 'selectionStart', '$0'.length) .should('have.prop', 'selectionEnd', '$0'.length) .type('5') .should('have.value', '$05 per day') .should('have.prop', 'selectionStart', '$05'.length) .should('have.prop', 'selectionEnd', '$05'.length) .blur() .should('have.value', '$5 per day'); }); it('$0.|05 per day => Backspace => blur => $|5 per day', () => { cy.get('@input') .type('0.05') .type('{leftArrow}'.repeat('05'.length)) .type('{backspace}') .should('have.value', '$005 per day') .should('have.prop', 'selectionStart', '$0'.length) .should('have.prop', 'selectionEnd', '$0'.length) .blur() .should('have.value', '$5 per day'); }); }); describe('cannot erase prefix', () => { it('Select all + Backspace', () => { cy.get('@input') .type('{selectAll}{backspace}') .should('have.value', '$ per day') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('$|42 per day => Backspace => $|42 per day', () => { cy.get('@input') .type('42') .type('{moveToStart}') .should('have.prop', 'selectionStart', '$'.length) .should('have.prop', 'selectionEnd', '$'.length) .type('{backspace}'.repeat(5)) .should('have.value', '$42 per day') .should('have.prop', 'selectionStart', '$'.length) .should('have.prop', 'selectionEnd', '$'.length); }); }); describe('cannot erase postfix', () => { it('Select all + Delete', () => { cy.get('@input') .type('{selectAll}{del}') .should('have.value', '$ per day') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('$42| per day => Delete x4 => $42| per day', () => { cy.get('@input') .type('42') .type('{moveToEnd}') .should('have.prop', 'selectionStart', '$42'.length) .should('have.prop', 'selectionEnd', '$42'.length) .type('{del}'.repeat(4)) .should('have.value', '$42 per day') .should('have.prop', 'selectionStart', '$42'.length) .should('have.prop', 'selectionEnd', '$42'.length); }); }); describe('with maskitoCaretGuard', () => { it('$|42 per day => ArrowLeft => $|42 per day', () => { cy.get('@input') .type('42') .type('{moveToStart}') .type('{leftArrow}'.repeat(3)) .should('have.value', '$42 per day') .should('have.prop', 'selectionStart', '$'.length) .should('have.prop', 'selectionEnd', '$'.length); }); }); }); describe('prefix/postfix ends/starts with the same character', () => { describe('[prefix]="$_" | [postfix]="_per_day" (with caret guard)', () => { beforeEach(() => { openNumberPage('prefix=$_&postfix=_per_day'); cy.get('@input') .should('have.value', '$__per_day') .should('have.prop', 'selectionStart', '$_'.length) .should('have.prop', 'selectionEnd', '$_'.length); }); it('$_|_per_day => Type Backspace => $_|_per_day', () => { cy.get('@input') .type('{backspace}') .should('have.value', '$__per_day') .should('have.prop', 'selectionStart', '$_'.length) .should('have.prop', 'selectionEnd', '$_'.length); }); it('$_|_per_day => Type Delete => $_|_per_day', () => { cy.get('@input') .type('{del}') .should('have.value', '$__per_day') .should('have.prop', 'selectionStart', '$_'.length) .should('have.prop', 'selectionEnd', '$_'.length); }); it('$_|_per_day => Select all + Delete => $_|_per_day', () => { cy.get('@input') .type('{selectAll}{del}') .should('have.value', '$__per_day') .should('have.prop', 'selectionStart', '$_'.length) .should('have.prop', 'selectionEnd', '$_'.length); }); }); }); describe('prefix ends with the same character as postfix starts', () => { const prefix = 'lbs.​'; // padded with zero-width space beforeEach(() => { openNumberPage('prefix=lbs.&maximumFractionDigits=2'); cy.get('@input') .focus() .should('have.value', prefix) .should('have.prop', 'selectionStart', prefix.length) .should('have.prop', 'selectionEnd', prefix.length); }); it('lbs.| => Type Backspace (attempt to erase zero-width space) => lbs.|', () => { cy.get('@input') .type('{backspace}') .should('have.value', prefix) .should('have.prop', 'selectionStart', prefix.length) .should('have.prop', 'selectionEnd', prefix.length); }); it('lbs.| => Type 42 => lbs.42|', () => { cy.get('@input') .type('42') .should('have.value', `${prefix}42`) .should('have.prop', 'selectionStart', `${prefix}42`.length) .should('have.prop', 'selectionEnd', `${prefix}42`.length); }); it('lbs.| => Type .42 => lbs.0.42|', () => { cy.get('@input') .type('.42') .should('have.value', `${prefix}0.42`) .should('have.prop', 'selectionStart', `${prefix}0.42`.length) .should('have.prop', 'selectionEnd', `${prefix}0.42`.length); }); it('lbs.0|.42 => Backspace + Blur => lbs.0.42', () => { cy.get('@input') .type('0.42') .type('{leftArrow}'.repeat('.42'.length)) .type('{backspace}') .should('have.value', `${prefix}.42`) .should('have.prop', 'selectionStart', prefix.length) .should('have.prop', 'selectionEnd', prefix.length) .blur() .wait(100) // to be sure that value is not changed even in case of some async validation .should('have.value', `${prefix}0.42`); }); }); describe('non-erasable minus (as [prefix]) for [max] <= 0', () => { beforeEach(() => { openNumberPage(`prefix=${encodeURIComponent(CHAR_MINUS)}&max=0`); }); it('shows minus sign on focus', () => { cy.get('@input').focus().should('have.value', CHAR_MINUS); }); it('hides minus sign on blur', () => { cy.get('@input') .focus() .should('have.value', CHAR_MINUS) .blur() .should('have.value', ''); }); it('forbids to enter more minuses', () => { cy.get('@input') .focus() .type(`${CHAR_MINUS}${CHAR_HYPHEN}${CHAR_EN_DASH}${CHAR_EM_DASH}`) .should('have.value', CHAR_MINUS); }); it('allows to enter 123 => Textfield value is -123', () => { cy.get('@input').focus().type('123').should('have.value', `${CHAR_MINUS}123`); }); it('Enter 123 and blur', () => { cy.get('@input') .focus() .type('123') .blur() .should('have.value', `${CHAR_MINUS}123`); }); describe('forbids all attempts to erase minus sign', () => { it('Select all + Backspace', () => { cy.get('@input') .focus() .type('{selectAll}{backspace}') .should('have.value', CHAR_MINUS) .type('123') .should('have.value', `${CHAR_MINUS}123`) .type('{selectAll}{backspace}') .should('have.value', CHAR_MINUS); }); it('Select all + Delete', () => { cy.get('@input') .focus() .type('{selectAll}{del}') .should('have.value', CHAR_MINUS) .type('123') .should('have.value', `${CHAR_MINUS}123`) .type('{selectAll}{del}') .should('have.value', CHAR_MINUS); }); it('Backspace', () => { cy.get('@input') .focus() .type('123') .should('have.value', `${CHAR_MINUS}123`) .type('{backspace}'.repeat(10)) .should('have.value', CHAR_MINUS); }); }); it('Impossible to move caret before minus sign', () => { cy.get('@input') .focus() .should('have.value', CHAR_MINUS) .type('{moveToStart}') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .type('{leftArrow}') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); }); describe('postfix consists of many characters `lbs_per_day`', () => { it('Paste 100 + incomplete postfix', () => { openNumberPage('postfix=lbs_per_day'); cy.get('@input') .focus() .should('have.value', 'lbs_per_day') .paste('100lbs') .should('have.value', '100lbs_per_day') .should('have.prop', 'selectionStart', '100'.length) .should('have.prop', 'selectionEnd', '100'.length); }); }); describe('postfix starts with point and contains digits ([postfix]=".000 km" & [maximumFractionDigits]="0")`', () => { beforeEach(() => { openNumberPage('postfix=.000%20km&maximumFractionDigits=0'); }); it('Adds postfix on focus', () => { cy.get('@input') .focus() .should('have.value', '.000 km') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('Removes postfix on blur (for empty value)', () => { cy.get('@input') .focus() .should('have.value', '.000 km') .blur() .should('have.value', ''); }); it('Type 1 => 1.000 km', () => { cy.get('@input') .focus() .type('1') .should('have.value', '1.000 km') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('Type 123 => 123.000 km', () => { cy.get('@input') .focus() .type('123') .should('have.value', '123.000 km') .should('have.prop', 'selectionStart', 3) .should('have.prop', 'selectionEnd', 3); }); it('Type 123456789 => 123 456 789.000 km', () => { cy.get('@input') .focus() .type('123456789') .should('have.value', '123 456 789.000 km') .should('have.prop', 'selectionStart', '123 456 789'.length) .should('have.prop', 'selectionEnd', '123 456 789'.length); }); it('Type 100 => 100.000 km', () => { cy.get('@input') .focus() .type('100') .should('have.value', '100.000 km') .should('have.prop', 'selectionStart', '100'.length) .should('have.prop', 'selectionEnd', '100'.length); }); it('1|.000 km => Backspace => |.000 km', () => { cy.get('@input') .focus() .type('1') .should('have.value', '1.000 km') .type('{backspace}') .should('have.value', '.000 km') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('|1.000 km => Delete => |.000 km', () => { cy.get('@input') .focus() .type('1') .type('{moveToStart}') .should('have.value', '1.000 km') .type('{del}') .should('have.value', '.000 km') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('123.000 km => select all + Delete => |.000 km', () => { cy.get('@input') .focus() .type('123') .should('have.value', '123.000 km') .type('{selectAll}{del}') .should('have.value', '.000 km') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('Allows to enter leading zeroes', () => { cy.get('@input') .focus() .type('000') .should('have.value', '000.000 km') .should('have.prop', 'selectionStart', '000'.length) .should('have.prop', 'selectionEnd', '000'.length); }); it('Removes duplicated leading zeroes on blur', () => { cy.get('@input') .focus() .type('000') .should('have.value', '000.000 km') .blur() .should('have.value', '0.000 km'); }); it('Ignores typing decimal separator (.) at start', () => { cy.get('@input') .focus() .should('have.value', '.000 km') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0) .type('.') .should('have.value', '.000 km') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('Non-digit characters are ignored', () => { cy.get('@input') .focus() .type('abc!@#') .should('have.value', '.000 km') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('123.000 km => Select all + Type 4 => 4.000 km', () => { cy.get('@input') .focus() .type('123') .should('have.value', '123.000 km') .type('{selectAll}4') .should('have.value', '4.000 km') .should('have.prop', 'selectionStart', '4'.length) .should('have.prop', 'selectionEnd', '4'.length); }); it('Caret guard: cannot move caret into postfix', () => { cy.get('@input') .focus() .type('123') .should('have.value', '123.000 km') .type('{moveToEnd}') .should('have.prop', 'selectionStart', '123'.length) .should('have.prop', 'selectionEnd', '123'.length) .type('{rightArrow}'.repeat(5)) .should('have.prop', 'selectionStart', '123'.length) .should('have.prop', 'selectionEnd', '123'.length); }); it('|.000 km => Backspace keeps value and caret', () => { cy.get('@input') .focus() .should('have.value', '.000 km') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0) .type('{backspace}'.repeat(5)) .should('have.value', '.000 km') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('|.000 km => Delete keeps value and caret', () => { cy.get('@input') .focus() .should('have.value', '.000 km') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0) .type('{del}') .should('have.value', '.000 km') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('Paste with leading text: abc123 => 123.000 km', () => { cy.get('@input') .focus() .paste('abc123') .should('have.value', '123.000 km') .should('have.prop', 'selectionStart', '123'.length) .should('have.prop', 'selectionEnd', '123'.length); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/number/number-thousand-separator.cy.ts ================================================ import {openNumberPage} from './utils'; describe('Number | thousandSeparator', () => { describe('adds thousand separator after pressing new digit', () => { beforeEach(() => { openNumberPage('thousandSeparator=-'); }); const tests = [ // [Typed value, Masked value] ['1', '1'], ['10', '10'], ['100', '100'], ['1000', '1-000'], ['10000', '10-000'], ['100000', '100-000'], ['1000000', '1-000-000'], ] as const; tests.forEach(([typedValue, maskedValue]) => { it(`Type "${typedValue}" => "${maskedValue}"`, () => { cy.get('@input') .type(typedValue) .should('have.value', maskedValue) .should('have.prop', 'selectionStart', maskedValue.length) .should('have.prop', 'selectionEnd', maskedValue.length); }); }); }); describe('move thousand separator after deleting a digit (initial: 1-000-000)', () => { beforeEach(() => { openNumberPage('thousandSeparator=-'); cy.get('@input').type('1000000'); }); const tests = [ // [How many times "Backspace"-key was pressed, Masked value] [1, '100-000'], [2, '10-000'], [3, '1-000'], [4, '100'], [5, '10'], [6, '1'], ] as const; tests.forEach(([n, maskedValue]) => { it(`Backspace x${n} => "${maskedValue}"`, () => { cy.get('@input') .type('{backspace}'.repeat(n)) .should('have.value', maskedValue) .should('have.prop', 'selectionStart', maskedValue.length) .should('have.prop', 'selectionEnd', maskedValue.length); }); }); }); describe('Editing somewhere in the middle of a value (NOT the last character)', () => { beforeEach(() => { openNumberPage('thousandSeparator=-'); }); it('1-00|0-000 => Backspace => 10|0-000 => Type "5" => 1-05|0-000', () => { cy.get('@input') .type('1000000') .type('{leftArrow}'.repeat('0-000'.length)) .should('have.prop', 'selectionStart', '1-00'.length) .should('have.prop', 'selectionEnd', '1-00'.length) .type('{backspace}') .should('have.value', '100-000') .should('have.prop', 'selectionStart', '10'.length) .should('have.prop', 'selectionEnd', '10'.length) .type('5') .should('have.value', '1-050-000') .should('have.prop', 'selectionStart', '1-05'.length) .should('have.prop', 'selectionEnd', '1-05'.length); }); it('1-000-|000 => Backspace => 1-000|-000', () => { cy.get('@input') .type('1000000') .type('{leftArrow}'.repeat('000'.length)) .should('have.prop', 'selectionStart', '1-000-'.length) .should('have.prop', 'selectionEnd', '1-000-'.length) .type('{backspace}') .should('have.value', '1-000-000') .should('have.prop', 'selectionStart', '1-000'.length) .should('have.prop', 'selectionEnd', '1-000'.length); }); it('1-000|-000 => Delete => 1-000-|000', () => { cy.get('@input') .type('1000000') .type('{leftArrow}'.repeat('-000'.length)) .should('have.prop', 'selectionStart', '1-000'.length) .should('have.prop', 'selectionEnd', '1-000'.length) .type('{del}') .should('have.value', '1-000-000') .should('have.prop', 'selectionStart', '1-000-'.length) .should('have.prop', 'selectionEnd', '1-000-'.length); }); it('100-|000 => Delete => 10-0|00 => Type 5 => 100-5|00', () => { cy.get('@input') .type('100000') .type('{leftArrow}'.repeat('000'.length)) .should('have.prop', 'selectionStart', '100-'.length) .should('have.prop', 'selectionEnd', '100-'.length) .type('{del}') .should('have.value', '10-000') .should('have.prop', 'selectionStart', '10-0'.length) .should('have.prop', 'selectionEnd', '10-0'.length) .type('5') .should('have.value', '100-500') .should('have.prop', 'selectionStart', '100-5'.length) .should('have.prop', 'selectionEnd', '100-5'.length); }); it('100-|000 => Delete x3 => 100', () => { cy.get('@input') .type('100000') .type('{leftArrow}'.repeat('000'.length)) .should('have.prop', 'selectionStart', '100-'.length) .should('have.prop', 'selectionEnd', '100-'.length) .type('{del}'.repeat(3)) .should('have.value', '100') .should('have.prop', 'selectionStart', '100'.length) .should('have.prop', 'selectionEnd', '100'.length); }); it('1|-234-567 => Backspace => 234-567', () => { cy.get('@input') .type('1234567') .type('{moveToStart}{rightArrow}') .should('have.value', '1-234-567') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .type('{backspace}') .should('have.value', '234-567') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); }); it('allows to set empty string as thousand separator', () => { openNumberPage('thousandSeparator=-'); cy.get('tr').contains('[thousandSeparator]').parents('tr').find('input').clear(); cy.get('@input') .type('1000000') .should('have.value', '1000000') .should('have.prop', 'selectionStart', '1000000'.length) .should('have.prop', 'selectionEnd', '1000000'.length); }); describe('prevent insertion of extra spaces (thousand separator is equal to non-breaking space) on invalid positions', () => { beforeEach(() => openNumberPage()); it('paste value with extra leading and trailing spaces', () => { cy.get('@input') .paste(' 123456 ') .should('have.value', '123 456') .should('have.prop', 'selectionStart', '123 456'.length) .should('have.prop', 'selectionEnd', '123 456'.length); }); it('|123 => Press space => |123', () => { cy.get('@input') .type('123') .type('{moveToStart}') .type(' ') .should('have.value', '123') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('1|23 => Press space => 1|23', () => { cy.get('@input') .type('123') .type('{moveToStart}') .type('{rightArrow}') .type(' ') .should('have.value', '123') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('12|3 => Press space => 12|3', () => { cy.get('@input') .type('123') .type('{moveToStart}') .type('{rightArrow}'.repeat(2)) .type(' ') .should('have.value', '123') .should('have.prop', 'selectionStart', 2) .should('have.prop', 'selectionEnd', 2); }); it('123| => Press space => 123|', () => { cy.get('@input') .type('123') .type('{moveToEnd}') .type(' ') .should('have.value', '123') .should('have.prop', 'selectionStart', 3) .should('have.prop', 'selectionEnd', 3); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/number/number-zero-integer-part.cy.ts ================================================ import {openNumberPage} from './utils'; describe('Number | Zero integer part', () => { describe('User types decimal separator when input is empty (decimalSeparator="," && maximumFractionDigits=2)', () => { describe('without prefix / postfix', () => { beforeEach(() => { openNumberPage( 'thousandSeparator=_&decimalSeparator=,&maximumFractionDigits=2', ); }); it('Empty input => Type "," (decimal separator) => value is equal "0,"', () => { cy.get('@input') .type(',') .should('have.value', '0,') .should('have.prop', 'selectionStart', '0,'.length) .should('have.prop', 'selectionEnd', '0,'.length); }); it('Input has only minus sign => Type "," (decimal separator) => value is equal "-0,|"', () => { cy.get('@input') .type('-,') .should('have.value', '−0,') .should('have.prop', 'selectionStart', '−0,'.length) .should('have.prop', 'selectionEnd', '−0,'.length); }); it('Empty input => Type "." (pseudo decimal separator) => value is equal "0,"', () => { cy.get('@input') .type('.') .should('have.value', '0,') .should('have.prop', 'selectionStart', '0,'.length) .should('have.prop', 'selectionEnd', '0,'.length); }); it('Input has only minus sign => Type "." (pseudo decimal separator) => value is equal "-0,|"', () => { cy.get('@input') .type('-.') .should('have.value', '−0,') .should('have.prop', 'selectionStart', '−0,'.length) .should('have.prop', 'selectionEnd', '−0,'.length); }); it('Empty input => Type "ю" (pseudo decimal separator) => value is equal "0,"', () => { cy.get('@input') .type('ю') .should('have.value', '0,') .should('have.prop', 'selectionStart', '0,'.length) .should('have.prop', 'selectionEnd', '0,'.length); }); it('Empty input => Type "ю" (pseudo decimal separator) => value is equal "-0,"', () => { cy.get('@input') .type('-ю') .should('have.value', '−0,') .should('have.prop', 'selectionStart', '−0,'.length) .should('have.prop', 'selectionEnd', '−0,'.length); }); it('Textfield with any value => Select all => Type decimal separator => value is equal "0,"', () => { cy.get('@input') .type(',') .should('have.value', '0,') .type('{selectall}') .type(',') .should('have.value', '0,') .should('have.prop', 'selectionStart', '0,'.length) .should('have.prop', 'selectionEnd', '0,'.length); }); it('Textfield with any value => Select all => Type "." (pseudo decimal separator) => value is equal "0,"', () => { cy.get('@input') .type('1,23') .should('have.value', '1,23') .type('{selectall}') .type('.') .should('have.value', '0,') .should('have.prop', 'selectionStart', '0,'.length) .should('have.prop', 'selectionEnd', '0,'.length); }); }); describe('With prefix ($) & postfix (%)', () => { beforeEach(() => { openNumberPage( 'thousandSeparator=_&decimalSeparator=,&maximumFractionDigits=2&prefix=$&postfix=kg', ); cy.get('@input') .focused() .should('have.value', '$kg') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('Empty value (only prefix & postfix) => Type "," (decimal separator) => value is equal "0,"', () => { cy.get('@input') .type(',') .should('have.value', '$0,kg') .should('have.prop', 'selectionStart', '$0,'.length) .should('have.prop', 'selectionEnd', '$0,'.length); }); it('Empty value (only prefix & postfix) => Type "." (pseudo decimal separator) => value is equal "0,"', () => { cy.get('@input') .type('.') .should('have.value', '$0,kg') .should('have.prop', 'selectionStart', '$0,'.length) .should('have.prop', 'selectionEnd', '$0,'.length); }); it('Textfield with any value => Select all => Type decimal separator => value is equal "$0,kg"', () => { cy.get('@input') .type('1,23') .should('have.value', '$1,23kg') .type('{selectall}') .type(',') .should('have.value', '$0,kg') .should('have.prop', 'selectionStart', '$0,'.length) .should('have.prop', 'selectionEnd', '$0,'.length); }); it('Textfield with any value => Select all => Type pseudo decimal separator => value is equal "$0,kg"', () => { cy.get('@input') .type('1,23') .should('have.value', '$1,23kg') .type('{selectall}') .type('.') .should('have.value', '$0,kg') .should('have.prop', 'selectionStart', '$0,'.length) .should('have.prop', 'selectionEnd', '$0,'.length); }); }); }); describe('value cannot contain many leading zeroes after blur event', () => { it('maximumFractionDigits = 2 & positive number', () => { openNumberPage('thousandSeparator=_&maximumFractionDigits=2'); cy.get('@input') .type('0000000') .should('have.value', '0_000_000') .should('have.prop', 'selectionStart', '0_000_000'.length) .should('have.prop', 'selectionEnd', '0_000_000'.length) .blur() .should('have.value', '0'); }); it('maximumFractionDigits = 2 & negative number', () => { openNumberPage('thousandSeparator=_&maximumFractionDigits=2'); cy.get('@input') .type('-00000006') .should('have.value', '−00_000_006') .should('have.prop', 'selectionStart', '−00_000_006'.length) .should('have.prop', 'selectionEnd', '−00_000_006'.length) .blur() .should('have.value', '−6'); }); it('maximumFractionDigits = 0', () => { openNumberPage('thousandSeparator=_&maximumFractionDigits=0'); cy.get('@input') .type('0000000') .should('have.value', '0_000_000') .should('have.prop', 'selectionStart', '0_000_000'.length) .should('have.prop', 'selectionEnd', '0_000_000'.length) .blur() .should('have.value', '0'); }); it('1|-000-000 => Backspace => blur => 0', () => { openNumberPage('thousandSeparator=_&maximumFractionDigits=2'); cy.get('@input') .type('1000000') .should('have.value', '1_000_000') .type('{moveToStart}{rightArrow}') .type('{backspace}') .should('have.value', '000_000') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0) .blur() .should('have.value', '0'); }); it('remove leading zeroes (on blur only!) when decimal separator is removed (positive number)', () => { openNumberPage( 'thousandSeparator=_&decimalSeparator=,&maximumFractionDigits=5', ); cy.get('@input') .type('0,0005') .type('{moveToStart}') .type('{rightArrow}'.repeat('0,'.length)) .type('{backspace}') .should('have.value', '00_005') .should('have.prop', 'selectionStart', '0'.length) .should('have.prop', 'selectionEnd', '0'.length) .blur() .should('have.value', '5'); }); it('remove leading zeroes (on blur only!) when decimal separator is removed (negative number)', () => { openNumberPage( 'thousandSeparator=_&decimalSeparator=,&maximumFractionDigits=5', ); cy.get('@input') .type('-0,0005') .type('{moveToStart}') .type('{rightArrow}'.repeat('-0,'.length)) .type('{backspace}') .should('have.value', '−00_005') .should('have.prop', 'selectionStart', '-0'.length) .should('have.prop', 'selectionEnd', '-0'.length) .blur() .should('have.value', '−5'); }); }); describe('pads empty integer part with zero on blur (if decimal part exists)', () => { describe('Without prefix', () => { it('Positive number & decimal separator is comma', () => { openNumberPage('decimalSeparator=,&maximumFractionDigits=2'); cy.get('@input') .type('1,23') .type('{moveToStart}{rightArrow}{backspace}') .blur() .should('have.value', '0,23'); cy.get('@input') .parents('tui-input') .should('have.ngControlValue', '0,23'); }); it('Negative number & decimal separator is dot', () => { openNumberPage('decimalSeparator=.&maximumFractionDigits=2'); cy.get('@input') .type('-1.23') .type('{leftArrow}'.repeat('.23'.length)) .type('{backspace}') .blur() .should('have.value', '−0.23'); cy.get('@input') .parents('tui-input') .should('have.ngControlValue', '−0.23'); }); }); describe('With prefix', () => { it('Positive number & decimal separator is dot', () => { openNumberPage('decimalSeparator=.&maximumFractionDigits=2&prefix=$'); cy.get('@input') .type('1.23') .type('{leftArrow}'.repeat('.23'.length)) .type('{backspace}') .blur() .should('have.value', '$0.23'); cy.get('@input') .parents('tui-input') .should('have.ngControlValue', '$0.23'); }); it('Negative number & decimal separator is comma', () => { openNumberPage('decimalSeparator=,&prefix=>&maximumFractionDigits=2'); cy.get('@input') .type('-1,23') .type('{leftArrow}'.repeat(',23'.length)) .type('{backspace}') .blur() .should('have.value', '>−0,23'); cy.get('@input') .parents('tui-input') .should('have.ngControlValue', '>−0,23'); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/number/utils.ts ================================================ import {DemoPath} from '@demo/constants'; export function openNumberPage(queryParams = ''): void { cy.visit(`/${DemoPath.Number}/API?${queryParams}`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .clear() .as('input'); } ================================================ FILE: projects/demo-integrations/src/tests/kit/time/time-affixes.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Time | Prefix & Postfix', () => { describe('[postfix]=" left" + WITH caret guard', () => { beforeEach(() => { cy.visit(DemoPath.Time); cy.get('#affixes input') .first() .should('have.value', '05:00 left') .focus() .as('textfield'); }); it('basic typing works', () => { cy.get('@textfield') .type('{moveToStart}') .type('1234') .should('have.value', '12:34 left') .should('have.prop', 'selectionStart', '12:34'.length) .should('have.prop', 'selectionEnd', '12:34'.length); }); it('replaces deleted character by zero', () => { cy.get('@textfield') .type('{moveToStart}') .type('{rightArrow}'.repeat(2)) .should('have.value', '05:00 left') .should('have.prop', 'selectionStart', '05'.length) .should('have.prop', 'selectionEnd', '05'.length) .type('{backspace}') .should('have.value', '00:00 left') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('zero-padding for minute segment works', () => { cy.get('@textfield') .type('{moveToStart}') .type('9') .should('have.value', '09:00 left') .should('have.prop', 'selectionStart', '09:'.length) .should('have.prop', 'selectionEnd', '09:'.length); }); it('zero-padding for second segment works', () => { cy.get('@textfield') .type('{moveToStart}') .type('97') .should('have.value', '09:07 left') .should('have.prop', 'selectionStart', '09:07'.length) .should('have.prop', 'selectionEnd', '09:07'.length); }); it('replaceAll + delete => only non-removable postfix', () => { cy.get('@textfield') .type('{selectAll}{backspace}') .should('have.value', ' left') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('move caret left on attempt to remove colon by Backspace', () => { cy.get('@textfield') .type('{moveToStart}') .type('{rightArrow}'.repeat(3)) .should('have.value', '05:00 left') .should('have.prop', 'selectionStart', '05:'.length) .should('have.prop', 'selectionEnd', '05:'.length) .type('{backspace}') .should('have.value', '05:00 left') .should('have.prop', 'selectionStart', '05'.length) .should('have.prop', 'selectionEnd', '05'.length); }); it('move caret right on attempt to remove colon by Delete', () => { cy.get('@textfield') .type('{moveToStart}') .type('{rightArrow}'.repeat(2)) .should('have.value', '05:00 left') .should('have.prop', 'selectionStart', '05'.length) .should('have.prop', 'selectionEnd', '05'.length) .type('{del}') .should('have.value', '05:00 left') .should('have.prop', 'selectionStart', '05:'.length) .should('have.prop', 'selectionEnd', '05:'.length); }); it('allows to delete last digit without zero placeholder', () => { cy.get('@textfield') .type('{moveToStart}') .type('1234') .should('have.value', '12:34 left') .should('have.prop', 'selectionStart', '12:34'.length) .should('have.prop', 'selectionEnd', '12:34'.length) .type('{backspace}') .should('have.value', '12:3 left') .should('have.prop', 'selectionStart', '12:3'.length) .should('have.prop', 'selectionEnd', '12:3'.length) .type('{backspace}') .should('have.value', '12 left') .should('have.prop', 'selectionStart', '12'.length) .should('have.prop', 'selectionEnd', '12'.length) .type('{backspace}') .should('have.value', '1 left') .should('have.prop', 'selectionStart', '1'.length) .should('have.prop', 'selectionEnd', '1'.length) .type('{backspace}') .should('have.value', ' left') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); describe('with maskitoAddOnFocusPlugin + maskitoRemoveOnBlurPlugin', () => { it('removes postfix on blur if there are no more digits except postfix', () => { cy.get('@textfield') .type('{selectAll}{backspace}') .should('have.value', ' left') .blur() .should('have.value', ''); }); it('adds postfix on focus for empty textfield', () => { cy.get('@textfield') .type('{selectAll}{backspace}') .blur() .should('have.value', '') .focus() .should('have.value', ' left'); }); }); }); describe('[postfix]="left" + WITHOUT caret guard', () => { beforeEach(() => { cy.visit(`${DemoPath.Time}/API?mode=HH:MM:SS&postfix=left`); cy.get('#demo-content input') .should('be.visible') .should('have.value', '') .first() .focus() .as('textfield'); }); it('basic typing works', () => { cy.get('@textfield') .type('123456') .should('have.value', '12:34:56left') .should('have.prop', 'selectionStart', '12:34:56'.length) .should('have.prop', 'selectionEnd', '12:34:56'.length); }); it('replaces deleted character by zero', () => { cy.get('@textfield') .type('123456') .type('{leftArrow}'.repeat(':34:56'.length)) .type('{backspace}') .should('have.value', '10:34:56left') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('zero-padding for hour segment works', () => { cy.get('@textfield') .type('9') .should('have.value', '09left') .should('have.prop', 'selectionStart', '09'.length) .should('have.prop', 'selectionEnd', '09'.length); }); it('zero-padding for minutes segment works', () => { cy.get('@textfield') .type('97') .should('have.value', '09:07left') .should('have.prop', 'selectionStart', '09:07'.length) .should('have.prop', 'selectionEnd', '09:07'.length); }); it('zero-padding for seconds segment works', () => { cy.get('@textfield') .type('976') .should('have.value', '09:07:06left') .should('have.prop', 'selectionStart', '09:07:06'.length) .should('have.prop', 'selectionEnd', '09:07:06'.length); }); it('replaceAll + delete => only non-removable postfix', () => { cy.get('@textfield') .type('123456') .should('have.value', '12:34:56left') .type('{selectAll}{backspace}') .should('have.value', 'left') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('move caret left on attempt to remove colon by Backspace', () => { cy.get('@textfield') .type('123456') .type('{leftArrow}'.repeat('56'.length)) .should('have.prop', 'selectionStart', '12:34:'.length) .should('have.prop', 'selectionEnd', '12:34:'.length) .type('{backspace}') .should('have.value', '12:34:56left') .should('have.prop', 'selectionStart', '12:34'.length) .should('have.prop', 'selectionEnd', '12:34'.length); }); it('move caret right on attempt to remove colon by Delete', () => { cy.get('@textfield') .type('1234') .type('{leftArrow}'.repeat(':34'.length)) .should('have.prop', 'selectionStart', '12'.length) .should('have.prop', 'selectionEnd', '12'.length) .type('{del}') .should('have.value', '12:34left') .should('have.prop', 'selectionStart', '12:'.length) .should('have.prop', 'selectionEnd', '12:'.length); }); it('allows to delete last digit without zero placeholder', () => { cy.get('@textfield') .type('123456') .should('have.value', '12:34:56left') .should('have.prop', 'selectionStart', '12:34:56'.length) .should('have.prop', 'selectionEnd', '12:34:56'.length) .type('{backspace}'.repeat(3)) .should('have.value', '12:3left') .should('have.prop', 'selectionStart', '12:3'.length) .should('have.prop', 'selectionEnd', '12:3'.length) .type('{backspace}') .should('have.value', '12left') .should('have.prop', 'selectionStart', '12'.length) .should('have.prop', 'selectionEnd', '12'.length) .type('{backspace}') .should('have.value', '1left') .should('have.prop', 'selectionStart', '1'.length) .should('have.prop', 'selectionEnd', '1'.length) .type('{backspace}') .should('have.value', 'left') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); }); describe('[prefix]="Timeout"', () => { beforeEach(() => { cy.visit(`${DemoPath.Time}/API?mode=HH:MM:SS.MSS&prefix=Timeout`); cy.get('#demo-content input') .should('be.visible') .should('have.value', '') .first() .focus() .as('textfield'); }); it('basic typing works', () => { cy.get('@textfield') .type('123456789') .should('have.value', 'Timeout12:34:56.789') .should('have.prop', 'selectionStart', 'Timeout12:34:56.789'.length) .should('have.prop', 'selectionEnd', 'Timeout12:34:56.789'.length); }); it('replaces deleted character by zero', () => { cy.get('@textfield') .type('1234') .type('{leftArrow}'.repeat(':34'.length)) .type('{backspace}') .should('have.value', 'Timeout10:34') .should('have.prop', 'selectionStart', 'Timeout1'.length) .should('have.prop', 'selectionEnd', 'Timeout1'.length); }); it('zero-padding for hour segment works', () => { cy.get('@textfield') .type('9') .should('have.value', 'Timeout09') .should('have.prop', 'selectionStart', 'Timeout09'.length) .should('have.prop', 'selectionEnd', 'Timeout09'.length); }); it('zero-padding for minutes segment works', () => { cy.get('@textfield') .type('97') .should('have.value', 'Timeout09:07') .should('have.prop', 'selectionStart', 'Timeout09:07'.length) .should('have.prop', 'selectionEnd', 'Timeout09:07'.length); }); it('zero-padding for second segment works', () => { cy.get('@textfield') .type('976') .should('have.value', 'Timeout09:07:06') .should('have.prop', 'selectionStart', 'Timeout09:07:06'.length) .should('have.prop', 'selectionEnd', 'Timeout09:07:06'.length); }); it('replaceAll + delete => only non-removable postfix', () => { cy.get('@textfield') .type('123456789') .should('have.value', 'Timeout12:34:56.789') .type('{selectAll}{backspace}') .should('have.value', 'Timeout') .should('have.prop', 'selectionStart', 'Timeout'.length) .should('have.prop', 'selectionEnd', 'Timeout'.length); }); it('move caret left on attempt to remove colon by Backspace', () => { cy.get('@textfield') .type('1234') .type('{leftArrow}'.repeat('34'.length)) .should('have.prop', 'selectionStart', 'Timeout12:'.length) .should('have.prop', 'selectionEnd', 'Timeout12:'.length) .type('{backspace}') .should('have.value', 'Timeout12:34') .should('have.prop', 'selectionStart', 'Timeout12'.length) .should('have.prop', 'selectionEnd', 'Timeout12'.length); }); it('move caret right on attempt to remove colon by Delete', () => { cy.get('@textfield') .type('1234') .type('{leftArrow}'.repeat(':34'.length)) .should('have.prop', 'selectionStart', 'Timeout12'.length) .should('have.prop', 'selectionEnd', 'Timeout12'.length) .type('{del}') .should('have.value', 'Timeout12:34') .should('have.prop', 'selectionStart', 'Timeout12:'.length) .should('have.prop', 'selectionEnd', 'Timeout12:'.length); }); it('allows to delete last digit without zero placeholder', () => { cy.get('@textfield') .type('123456789') .should('have.value', 'Timeout12:34:56.789') .should('have.prop', 'selectionStart', 'Timeout12:34:56.789'.length) .should('have.prop', 'selectionEnd', 'Timeout12:34:56.789'.length) .type('{backspace}') .should('have.value', 'Timeout12:34:56.78') .should('have.prop', 'selectionStart', 'Timeout12:34:56.78'.length) .should('have.prop', 'selectionEnd', 'Timeout12:34:56.78'.length) .type('{backspace}'.repeat(2)) .should('have.value', 'Timeout12:34:56') .should('have.prop', 'selectionStart', 'Timeout12:34:56'.length) .should('have.prop', 'selectionEnd', 'Timeout12:34:56'.length) .type('{backspace}'.repeat(3)) .should('have.value', 'Timeout12:3') .should('have.prop', 'selectionStart', 'Timeout12:3'.length) .should('have.prop', 'selectionEnd', 'Timeout12:3'.length) .type('{backspace}'.repeat(3)) .should('have.value', 'Timeout') .should('have.prop', 'selectionStart', 'Timeout'.length) .should('have.prop', 'selectionEnd', 'Timeout'.length) .type('{backspace}'.repeat(5)) .should('have.value', 'Timeout') .should('have.prop', 'selectionStart', 'Timeout'.length) .should('have.prop', 'selectionEnd', 'Timeout'.length); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/time/time-basic.cy.ts ================================================ import {DemoPath} from '@demo/constants'; import {BROWSER_SUPPORTS_REAL_EVENTS} from '../../../support/constants'; import {repeatKey} from '../../utils'; describe('Time', () => { describe('Basic', () => { beforeEach(() => { cy.visit(`/${DemoPath.Time}/API?mode=HH:MM`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); describe('basic typing (1 character per keydown)', () => { const tests = [ // [Typed value, Masked value, caretIndex] ['1', '1', 1], ['12', '12', '12'.length], ['12:', '12:', '12:'.length], ['123', '12:3', '12:3'.length], ['1234', '12:34', '12:34'.length], ] as const; tests.forEach(([typedValue, maskedValue, caretIndex]) => { it(`Type "${typedValue}" => "${maskedValue}"`, () => { cy.get('@input') .type(typedValue) .should('have.value', maskedValue) .should('have.prop', 'selectionStart', caretIndex) .should('have.prop', 'selectionEnd', caretIndex); }); }); }); describe('basic erasing (value = "12:34" & caret is placed after the last value)', () => { beforeEach(() => { cy.get('@input').type('1234'); }); const tests = [ // [How many times "Backspace"-key was pressed, caretPosition, Masked value] [1, '12:3'.length, '12:3'], [2, '12'.length, '12'], [3, '1'.length, '1'], [4, 0, ''], ] as const; tests.forEach(([n, caretIndex, maskedValue]) => { it(`Backspace x${n} => "${maskedValue}"`, () => { cy.get('@input') .type('{backspace}'.repeat(n)) .should('have.value', maskedValue) .should('have.prop', 'selectionStart', caretIndex) .should('have.prop', 'selectionEnd', caretIndex); }); }); it('Delete => no value change && no caret index change', () => { cy.get('@input') .type('{del}') .should('have.value', '12:34') .should('have.prop', 'selectionStart', '12:34'.length) .should('have.prop', 'selectionEnd', '12:34'.length); }); }); describe('Editing somewhere in the middle of a value (NOT the last character)', () => { it('12:3|4 => Backspace => 12:|04 => Type "5" => 12:5|4', () => { cy.get('@input') .type('1234') .type('{leftArrow}') .should('have.prop', 'selectionStart', '12:3'.length) .should('have.prop', 'selectionEnd', '12:3'.length) .type('{backspace}') .should('have.value', '12:04') .should('have.prop', 'selectionStart', '12:'.length) .should('have.prop', 'selectionEnd', '12:'.length) .type('5') .should('have.value', '12:54') .should('have.prop', 'selectionStart', '12:5'.length) .should('have.prop', 'selectionEnd', '12:5'.length); }); it('12|:34 => Backspace => 1|0:34 => Type "1" => 11:|34', () => { cy.get('@input') .type('1234') .type('{leftArrow}'.repeat(':34'.length)) .should('have.prop', 'selectionStart', '12'.length) .should('have.prop', 'selectionEnd', '12'.length) .type('{backspace}') .should('have.value', '10:34') .should('have.prop', 'selectionStart', '1'.length) .should('have.prop', 'selectionEnd', '1'.length) .type('1') .should('have.value', '11:34') .should('have.prop', 'selectionStart', '11:'.length) .should('have.prop', 'selectionEnd', '11:'.length); }); it('1|2:34 => Backspace => |02:34 => Type "2" => 2|2:34', () => { cy.get('@input') .type('1234') .type('{leftArrow}'.repeat('2:34'.length)) .should('have.prop', 'selectionStart', '1'.length) .should('have.prop', 'selectionEnd', '1'.length) .type('{backspace}') .should('have.value', '02:34') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0) .type('2') .should('have.value', '22:34') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('12:|34 => Type "9" => 12:09|', () => { cy.get('@input') .type('1234') .type('{leftArrow}'.repeat('34'.length)) .should('have.prop', 'selectionStart', '12:'.length) .should('have.prop', 'selectionEnd', '12:'.length) .type('9') .should('have.value', '12:09') .should('have.prop', 'selectionStart', '12:09'.length) .should('have.prop', 'selectionEnd', '12:09'.length); }); it('|19:45 => Type "2" => 2|0:45', () => { cy.get('@input') .type('1945') .type('{moveToStart}') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0) .type('2') .should('have.value', '20:45') .should('have.prop', 'selectionStart', '2'.length) .should('have.prop', 'selectionEnd', '2'.length); }); }); describe('Fixed values', () => { it('Press Backspace after fixed value => no value change => move caret to the left', () => { cy.get('@input') .type('2359') .type('{leftArrow}'.repeat('59'.length)) .should('have.prop', 'selectionStart', '23:'.length) .should('have.prop', 'selectionEnd', '23:'.length) .type('{backspace}') .should('have.value', '23:59') .should('have.prop', 'selectionStart', '23'.length) .should('have.prop', 'selectionEnd', '23'.length); }); it('Press Delete after fixed value => no value change => move caret to the right', () => { cy.get('@input') .type('2359') .type('{leftArrow}'.repeat(':59'.length)) .should('have.prop', 'selectionStart', '23'.length) .should('have.prop', 'selectionEnd', '23'.length) .type('{del}') .should('have.value', '23:59') .should('have.prop', 'selectionStart', '23:'.length) .should('have.prop', 'selectionEnd', '23:'.length); }); }); describe('Text selection', () => { describe('Select range and press Backspace', () => { it('12:|34| => Backspace => 12', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('1234') .realPress([ 'Shift', ...repeatKey('ArrowLeft', '34'.length), 'Backspace', ]); cy.get('@input') .should('have.value', '12') .should('have.prop', 'selectionStart', '12'.length) .should('have.prop', 'selectionEnd', '12'.length); }); it('1|2:3|4 => Backspace => 1|0:04', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('1234') .realPress([ 'ArrowLeft', 'Shift', ...repeatKey('ArrowLeft', '2:3'.length), 'Backspace', ]); cy.get('@input') .should('have.value', '10:04') .should('have.prop', 'selectionStart', '1'.length) .should('have.prop', 'selectionEnd', '1'.length); }); it('|12|:34 => Backspace => |00:34', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('1234') .realPress([ ...repeatKey('ArrowLeft', ':34'.length), 'Shift', ...repeatKey('ArrowLeft', '12'.length), 'Backspace', ]); cy.get('@input') .should('have.value', '00:34') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); }); describe('Select range and press "Delete"', () => { it('23:|59| => Delete => 23', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('2359') .realPress(['Shift', ...repeatKey('ArrowLeft', '59'.length)]); cy.get('@input') .type('{del}') .should('have.value', '23') .should('have.prop', 'selectionStart', '23'.length) .should('have.prop', 'selectionEnd', '23'.length); }); it('2|3:5|9 => Delete => 20:0|9', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('2359') .realPress([ 'ArrowLeft', 'Shift', ...repeatKey('ArrowLeft', '3:5'.length), ]); cy.get('@input') .type('{del}') .should('have.value', '20:09') .should('have.prop', 'selectionStart', '20:0'.length) .should('have.prop', 'selectionEnd', '20:0'.length); }); it('|23|:59 => Delete => 00|:59', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('2359') .realPress([ ...repeatKey('ArrowLeft', ':59'.length), 'Shift', ...repeatKey('ArrowLeft', '23'.length), ]); cy.get('@input') .type('{del}') .should('have.value', '00:59') .should('have.prop', 'selectionStart', '00'.length) .should('have.prop', 'selectionEnd', '00'.length); }); }); describe('Select range and press new digit', () => { it('11:|22| => Press 3 => 11:3|', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('1122') .realPress(['Shift', ...repeatKey('ArrowLeft', '22'.length)]); cy.get('@input') .type('3') .should('have.value', '11:3') .should('have.prop', 'selectionStart', '11:3'.length) .should('have.prop', 'selectionEnd', '11:3'.length); }); it('1|1:2|2 => Press 3 => 13:|02', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('1122') .realPress([ 'ArrowLeft', 'Shift', ...repeatKey('ArrowLeft', '1:2'.length), ]); cy.get('@input') .type('3') .should('have.value', '13:02') .should('have.prop', 'selectionStart', '13:'.length) .should('have.prop', 'selectionEnd', '13:'.length); }); it('|11|:33 => Press 2 => 2|0:33', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('1133') .realPress([ ...repeatKey('ArrowLeft', ':33'.length), 'Shift', ...repeatKey('ArrowLeft', '11'.length), ]); cy.get('@input') .type('2') .should('have.value', '20:33') .should('have.prop', 'selectionStart', '2'.length) .should('have.prop', 'selectionEnd', '2'.length); }); it('1|2|:34 => Press 9 => 19:|34', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('1234') .realPress([ ...repeatKey('ArrowLeft', ':34'.length), 'Shift', 'ArrowLeft', ]); cy.get('@input') .type('9') .should('have.value', '19:34') .should('have.prop', 'selectionStart', '19:'.length) .should('have.prop', 'selectionEnd', '19:'.length); }); }); }); describe('Undo', () => { it('Select all + Delete => Ctrl + Z', () => { cy.get('@input') .type('1743') .type('{selectall}{del}') .should('have.value', '') .type('{ctrl+z}') .should('have.value', '17:43'); }); it('11|:22 => Backspace (x2) => Ctrl + Z (x2)', () => { cy.get('@input') .type('1122') .type('{leftArrow}'.repeat(':22'.length)) .type('{backspace}{backspace}') .should('have.value', '00:22') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0) .type('{ctrl+z}') .should('have.value', '10:22') .should('have.prop', 'selectionStart', '1'.length) .should('have.prop', 'selectionEnd', '1'.length) .type('{ctrl+z}') .should('have.value', '11:22') .should('have.prop', 'selectionStart', '11'.length) .should('have.prop', 'selectionEnd', '11'.length); }); it('12:|34 => Delete => Cmd + Z', () => { cy.get('@input') .type('1234') .type('{leftArrow}{leftArrow}') .type('{del}') .should('have.value', '12:04') .should('have.prop', 'selectionStart', '12:0'.length) .should('have.prop', 'selectionEnd', '12:0'.length) .type('{cmd+z}') .should('have.value', '12:34') .should('have.prop', 'selectionStart', '12:'.length) .should('have.prop', 'selectionEnd', '12:'.length); }); }); describe('Redo', () => { it('Select all + Delete => Cmd + Z => Cmd + Shift + Z', () => { cy.get('@input') .type('1743') .type('{selectall}{del}') .should('have.value', '') .type('{cmd+z}') .should('have.value', '17:43') .type('{cmd+shift+z}') .should('have.value', ''); }); it('11|:22 => Backspace (x2) => Ctrl + Z (x2) => Ctrl + Y (x2)', () => { cy.get('@input') .type('1122') .type('{leftArrow}'.repeat(':22'.length)) .type('{backspace}{backspace}') .type('{ctrl+z}') .type('{ctrl+z}') .type('{ctrl+y}') .type('{ctrl+y}') .should('have.value', '00:22') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('12:|34 => Delete => Cmd + Z => Ctrl + Y', () => { cy.get('@input') .type('1234') .type('{leftArrow}{leftArrow}') .type('{del}') .type('{cmd+z}') .type('{cmd+shift+z}') .should('have.value', '12:04') .should('have.prop', 'selectionStart', '12:0'.length) .should('have.prop', 'selectionEnd', '12:0'.length); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/time/time-fullwidth-to-halfwidth.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Time', () => { describe('Full width character parsing', () => { beforeEach(() => { cy.visit(`/${DemoPath.Time}/API?mode=HH:MM`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('input'); }); describe('basic typing (1 character per keydown)', () => { const tests = [ // [Typed value, Masked value, caretIndex] ['1', '1', 1], ['12', '12', '12'.length], ['12:', '12:', '12:'.length], ['123', '12:3', '12:3'.length], ['1234', '12:34', '12:34'.length], ] as const; tests.forEach(([typedValue, maskedValue, caretIndex]) => { it(`Type "${typedValue}" => "${maskedValue}"`, () => { cy.get('@input') .type(typedValue) .should('have.value', maskedValue) .should('have.prop', 'selectionStart', caretIndex) .should('have.prop', 'selectionEnd', caretIndex); }); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/time/time-meridiem.cy.ts ================================================ import {DemoPath} from '@demo/constants'; import {range, withCaretLabel} from '../../utils'; describe('Time | modes with meridiem', () => { describe('HH:MM AA', () => { beforeEach(() => { cy.visit(`/${DemoPath.Time}/API?mode=HH:MM%20AA`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('textfield'); }); describe('basic text insertion works', () => { it('Empty textfield => Type 1234AM => 12:34 AM', () => { cy.get('@textfield') .type('1234AM') .should('have.value', '12:34 AM') .should('have.prop', 'selectionStart', '12:34 AM'.length) .should('have.prop', 'selectionEnd', '12:34 AM'.length); }); it('12:34| => Type lowercase `a` => 12:34 AM', () => { cy.get('@textfield') .type('1234a') .should('have.value', '12:34 AM') .should('have.prop', 'selectionStart', '12:34 AM'.length) .should('have.prop', 'selectionEnd', '12:34 AM'.length); }); it('12:34| => Type uppercase `A` => 12:34 AM', () => { cy.get('@textfield') .type('1234A') .should('have.value', '12:34 AM') .should('have.prop', 'selectionStart', '12:34 AM'.length) .should('have.prop', 'selectionEnd', '12:34 AM'.length); }); it('12:34| => Type lowercase `p` => 12:34 AM', () => { cy.get('@textfield') .type('1234p') .should('have.value', '12:34 PM') .should('have.prop', 'selectionStart', '12:34 PM'.length) .should('have.prop', 'selectionEnd', '12:34 PM'.length); }); it('12:34| => Type uppercase `P` => 12:34 AM', () => { cy.get('@textfield') .type('1234P') .should('have.value', '12:34 PM') .should('have.prop', 'selectionStart', '12:34 PM'.length) .should('have.prop', 'selectionEnd', '12:34 PM'.length); }); it('12:34| => Type lowercase `m` => 12:34', () => { cy.get('@textfield') .type('1234m') .should('have.value', '12:34') .should('have.prop', 'selectionStart', '12:34'.length) .should('have.prop', 'selectionEnd', '12:34'.length); }); it('12:34| => Type uppercase `M` => 12:34', () => { cy.get('@textfield') .type('1234M') .should('have.value', '12:34') .should('have.prop', 'selectionStart', '12:34'.length) .should('have.prop', 'selectionEnd', '12:34'.length); }); }); describe('deletion of any meridiem characters deletes all meridiem character', () => { [ {caretIndex: '12:34 AM'.length, action: '{backspace}'}, {caretIndex: '12:34 A'.length, action: '{backspace}'}, {caretIndex: '12:34 '.length, action: '{del}'}, {caretIndex: '12:34 A'.length, action: '{del}'}, ].forEach(({caretIndex, action}) => { const initialValue = '12:34 AM'; it(`${withCaretLabel(initialValue, caretIndex)} => ${action} => 12:34|`, () => { cy.get('@textfield') .type('1234a') .type('{moveToStart}') .type('{rightArrow}'.repeat(caretIndex)) .type(action) .should('have.value', '12:34') .should('have.prop', 'selectionStart', '12:34'.length) .should('have.prop', 'selectionEnd', '12:34'.length); }); }); }); describe('type new meridiem value when textfield already has another one', () => { it('12:34 AM| => Type P => 12:34 PM|', () => { cy.get('@textfield') .type('1234a') .type('{moveToEnd}') .type('p') .should('have.value', '12:34 PM') .should('have.prop', 'selectionStart', '12:34 PM'.length) .should('have.prop', 'selectionEnd', '12:34 PM'.length); }); it('12:34 A|M => Type P => 12:34 PM|', () => { cy.get('@textfield') .type('1234a') .type('{moveToEnd}{leftArrow}') .type('p') .should('have.value', '12:34 PM') .should('have.prop', 'selectionStart', '12:34 PM'.length) .should('have.prop', 'selectionEnd', '12:34 PM'.length); }); it('12:34 |AM => Type P => 12:34 PM|', () => { cy.get('@textfield') .type('1234a') .type('{moveToEnd}') .type('{leftArrow}'.repeat(2)) .type('p') .should('have.value', '12:34 PM') .should('have.prop', 'selectionStart', '12:34 PM'.length) .should('have.prop', 'selectionEnd', '12:34 PM'.length); }); it('12:34| AM => Type P => 12:34 PM|', () => { cy.get('@textfield') .type('1234a') .type('{moveToEnd}') .type('{leftArrow}'.repeat(' AM'.length)) .type('p') .should('have.value', '12:34 PM') .should('have.prop', 'selectionStart', '12:34 PM'.length) .should('have.prop', 'selectionEnd', '12:34 PM'.length); }); it('12:34 PM| => Type A => 12:34 AM|', () => { cy.get('@textfield') .type('1234p') .type('{moveToEnd}') .type('a') .should('have.value', '12:34 AM') .should('have.prop', 'selectionStart', '12:34 AM'.length) .should('have.prop', 'selectionEnd', '12:34 AM'.length); }); it('12:34 P|M => Type A => 12:34 AM|', () => { cy.get('@textfield') .type('1234p') .type('{moveToEnd}{leftArrow}') .type('A') .should('have.value', '12:34 AM') .should('have.prop', 'selectionStart', '12:34 AM'.length) .should('have.prop', 'selectionEnd', '12:34 AM'.length); }); it('12:34 |PM => Type A => 12:34 AM|', () => { cy.get('@textfield') .type('1234p') .type('{moveToEnd}') .type('{leftArrow}'.repeat(2)) .type('a') .should('have.value', '12:34 AM') .should('have.prop', 'selectionStart', '12:34 AM'.length) .should('have.prop', 'selectionEnd', '12:34 AM'.length); }); it('12:34| PM => Type A => 12:34 AM|', () => { cy.get('@textfield') .type('1234p') .type('{moveToEnd}') .type('{leftArrow}'.repeat(' PM'.length)) .type('a') .should('have.value', '12:34 AM') .should('have.prop', 'selectionStart', '12:34 AM'.length) .should('have.prop', 'selectionEnd', '12:34 AM'.length); }); }); describe('press any characters (except part of meridiem ones) when cursor is placed near already existing meridiem', () => { beforeEach(() => { cy.get('@textfield') .type('1234a') .should('have.value', '12:34 AM') .should('have.prop', 'selectionStart', '12:34 AM'.length) .should('have.prop', 'selectionEnd', '12:34 AM'.length); }); it('12:34 AM| => Press 1 => Nothing changed', () => { cy.get('@textfield') .type('{moveToEnd}') .type('1') .should('have.value', '12:34 AM') .should('have.prop', 'selectionStart', '12:34 AM'.length) .should('have.prop', 'selectionEnd', '12:34 AM'.length); }); it('12:34 A|M => Press 1 => Nothing changed', () => { cy.get('@textfield') .type('{moveToEnd}{leftArrow}') .type('1') .should('have.value', '12:34 AM') .should('have.prop', 'selectionStart', '12:34 A'.length) .should('have.prop', 'selectionEnd', '12:34 A'.length); }); it('12:34 A|M => Press T => Nothing changed', () => { cy.get('@textfield') .type('{moveToEnd}{leftArrow}') .type('t') .should('have.value', '12:34 AM') .should('have.prop', 'selectionStart', '12:34 A'.length) .should('have.prop', 'selectionEnd', '12:34 A'.length); }); it('12:34 |AM => Press T => Nothing changed', () => { cy.get('@textfield') .type('{moveToEnd}') .type('{leftArrow}'.repeat(2)) .type('t') .should('have.value', '12:34 AM') .should('have.prop', 'selectionStart', '12:34 '.length) .should('have.prop', 'selectionEnd', '12:34 '.length); }); }); describe('hour segment bounds', () => { it('cannot be less than 01 (rejects zero as the 2nd hour segment)', () => { cy.get('@textfield') .type('00') .should('have.value', '0') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('can be 1 (as the 1st digit segment)', () => { cy.get('@textfield') .type('1') .should('have.value', '1') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); describe('automatically pads with zero', () => { range(2, 9).forEach((x) => { it(`on attempt to enter ${x} as the first hour segment`, () => { cy.get('@textfield') .type(String(x)) .should('have.value', `0${x}`) .should('have.prop', 'selectionStart', 2) .should('have.prop', 'selectionEnd', 2); }); }); }); range(10, 12).forEach((x) => { const value = String(x); it(`can be ${x}`, () => { cy.get('@textfield') .type(value) .should('have.value', value) .should('have.prop', 'selectionStart', 2) .should('have.prop', 'selectionEnd', 2); }); }); describe('rejects insertion', () => { range(13, 19).forEach((x) => { it(`on attempt to enter ${x} as the last hour segment`, () => { cy.get('@textfield') .type(String(x)) .should('have.value', '1') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); }); }); }); describe('toggle meridiem value on ArrowUp / ArrowDown', () => { describe('Initial value === "12:34 |"', () => { beforeEach(() => { cy.get('@textfield').type('1234 '); }); it('↑ --- 12:34 |AM', () => { cy.get('@textfield') .type('{upArrow}') .should('have.value', '12:34 AM') .should('have.prop', 'selectionStart', '12:34 '.length) .should('have.prop', 'selectionEnd', '12:34 '.length); }); it('↓ --- 12:34 |PM', () => { cy.get('@textfield') .type('{downArrow}') .should('have.value', '12:34 PM') .should('have.prop', 'selectionStart', '12:34 '.length) .should('have.prop', 'selectionEnd', '12:34 '.length); }); }); describe('Initial value === "12:34 AM"', () => { const initialValue = '12:34 AM'; const toggledValue = '12:34 PM'; beforeEach(() => { cy.get('@textfield') .type('1234a') .should('have.value', initialValue) .type('{moveToStart}'); }); ['12:34 '.length, '12:34 A'.length, '12:34 AM'.length].forEach( (initialCaretIndex) => { const initialValueWithCaretLabel = withCaretLabel( initialValue, initialCaretIndex, ); const toggledValueWithCaretLabel = withCaretLabel( toggledValue, initialCaretIndex, ); it(`${initialValueWithCaretLabel} --- ↑ --- ${toggledValueWithCaretLabel}`, () => { cy.get('@textfield') .type('{rightArrow}'.repeat(initialCaretIndex)) .type('{upArrow}') .should('have.value', toggledValue) .should('have.prop', 'selectionStart', initialCaretIndex) .should('have.prop', 'selectionEnd', initialCaretIndex); }); it(`${initialValueWithCaretLabel} --- ↓ --- ${toggledValueWithCaretLabel}`, () => { cy.get('@textfield') .type('{rightArrow}'.repeat(initialCaretIndex)) .type('{downArrow}') .should('have.value', toggledValue) .should('have.prop', 'selectionStart', initialCaretIndex) .should('have.prop', 'selectionEnd', initialCaretIndex); }); }, ); }); describe('Initial value === "01:01 PM"', () => { const initialValue = '01:01 PM'; const toggledValue = '01:01 AM'; beforeEach(() => { cy.get('@textfield') .type('0101p') .should('have.value', initialValue) .type('{moveToStart}'); }); ['01:01 '.length, '01:01 P'.length, '01:01 PM'.length].forEach( (initialCaretIndex) => { const initialValueWithCaretLabel = withCaretLabel( initialValue, initialCaretIndex, ); const toggledValueWithCaretLabel = withCaretLabel( toggledValue, initialCaretIndex, ); it(`${initialValueWithCaretLabel} --- ↑ --- ${toggledValueWithCaretLabel}`, () => { cy.get('@textfield') .type('{rightArrow}'.repeat(initialCaretIndex)) .type('{upArrow}') .should('have.value', toggledValue) .should('have.prop', 'selectionStart', initialCaretIndex) .should('have.prop', 'selectionEnd', initialCaretIndex); }); it(`${initialValueWithCaretLabel} --- ↓ --- ${toggledValueWithCaretLabel}`, () => { cy.get('@textfield') .type('{rightArrow}'.repeat(initialCaretIndex)) .type('{downArrow}') .should('have.value', toggledValue) .should('have.prop', 'selectionStart', initialCaretIndex) .should('have.prop', 'selectionEnd', initialCaretIndex); }); }, ); }); describe('do nothing when caret is put after any time segment', () => { it('Empty textfield --- ↑↓ --- Empty textfield', () => { cy.get('@textfield') .should('have.value', '') .type('{upArrow}') .should('have.value', '') .type('{downArrow}') .should('have.value', ''); }); ['1', '12', '12:', '12:3', '12:34'].forEach((textfieldValue) => { it(`${textfieldValue} --- ↑↓ --- ${textfieldValue}`, () => { cy.get('@textfield') .type(textfieldValue) .should('have.value', textfieldValue) .type('{upArrow}') .should('have.value', textfieldValue) .type('{downArrow}') .should('have.value', textfieldValue); }); }); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/time/time-mode.cy.ts ================================================ import {DemoPath} from '@demo/constants'; import {BROWSER_SUPPORTS_REAL_EVENTS} from '../../../support/constants'; import {repeatKey} from '../../utils'; describe('Time', () => { describe('Mode', () => { describe('HH:MM', () => { beforeEach(() => { cy.visit(`/${DemoPath.Time}/API?mode=HH:MM`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .clear() .as('input'); }); describe('Typing new character overwrite character after cursor', () => { it('new character is different from the next one', () => { cy.get('@input') .type('2359') .type('{moveToStart}') .type('00') .should('have.value', '00:59') .should('have.prop', 'selectionStart', '00:'.length) .should('have.prop', 'selectionEnd', '00:'.length) .type('00') .should('have.value', '00:00') .should('have.prop', 'selectionStart', '00:00'.length) .should('have.prop', 'selectionEnd', '00:00'.length); }); it('moves cursor behind next character if new character is the same with the next one', () => { cy.get('@input') .type('0259') .type('{moveToStart}{rightArrow}') .type('2') .should('have.value', '02:59') .should('have.prop', 'selectionStart', '02:'.length) .should('have.prop', 'selectionEnd', '02:'.length); }); }); describe('Select range and press new digit', () => { it('|23|:59 => Type 1 => 1|0:59', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('235959') .should('have.value', '23:59') .realPress([ ...repeatKey('ArrowLeft', ':59'.length), 'Shift', ...repeatKey('ArrowLeft', '23'.length), ]); cy.get('@input') .type('1') .should('have.value', '10:59') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('|23|:59 => Type 0 => 0|0:59', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('235959') .should('have.value', '23:59') .realPress([ ...repeatKey('ArrowLeft', ':59'.length), 'Shift', ...repeatKey('ArrowLeft', '23'.length), ]); cy.get('@input') .type('0') .should('have.value', '00:59') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); }); it('Pad typed value with zero if digit exceeds the first digit of time segment', () => { cy.get('@input') .type('9') .should('have.value', '09') .should('have.prop', 'selectionStart', '09'.length) .should('have.prop', 'selectionEnd', '09'.length) .type('9') .should('have.value', '09:09') .should('have.prop', 'selectionStart', '09:09'.length) .should('have.prop', 'selectionEnd', '09:09'.length); }); it('cannot insert >23 hours', () => { cy.get('@input') .type('2') .should('have.value', '2') .type('7') .should('have.value', '2') .should('have.prop', 'selectionStart', '2'.length) .should('have.prop', 'selectionEnd', '2'.length); }); }); describe('HH:MM:SS', () => { beforeEach(() => { cy.visit(`/${DemoPath.Time}/API?mode=HH:MM:SS`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .clear() .as('input'); }); describe('Typing new character overwrite character after cursor', () => { it('new character is different from the next one', () => { cy.get('@input') .type('235959') .type('{moveToStart}') .type('0') .should('have.value', '03:59:59') .should('have.prop', 'selectionStart', '0'.length) .should('have.prop', 'selectionEnd', '0'.length) .type('000') .should('have.value', '00:00:59') .should('have.prop', 'selectionStart', '00:00:'.length) .should('have.prop', 'selectionEnd', '00:00:'.length) .type('00') .should('have.value', '00:00:00') .should('have.prop', 'selectionStart', '00:00:00'.length) .should('have.prop', 'selectionEnd', '00:00:00'.length); }); it('moves cursor behind next character if new character is the same with the next one', () => { cy.get('@input') .type('02:59:59') .should('have.value', '02:59:59') .type('{moveToStart}') .type('{rightArrow}'.repeat('02:'.length)) .type('5') .should('have.value', '02:59:59') .should('have.prop', 'selectionStart', '02:5'.length) .should('have.prop', 'selectionEnd', '02:5'.length); }); }); describe('Select range and press new digit', () => { it( '23:|59|:59 => Type 2 => 23:2|0:59', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('235959') .should('have.value', '23:59:59') .realPress([ ...repeatKey('ArrowLeft', ':59'.length), 'Shift', ...repeatKey('ArrowLeft', '59'.length), ]); cy.get('@input') .type('2') .should('have.value', '23:20:59') .should('have.prop', 'selectionStart', '23:2'.length) .should('have.prop', 'selectionEnd', '23:2'.length); }, ); it( '23:|59|:59 => Type 0 => 23:0|0:59', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('235959') .should('have.value', '23:59:59') .realPress([ ...repeatKey('ArrowLeft', ':59'.length), 'Shift', ...repeatKey('ArrowLeft', '59'.length), ]); cy.get('@input') .type('0') .should('have.value', '23:00:59') .should('have.prop', 'selectionStart', '23:0'.length) .should('have.prop', 'selectionEnd', '23:0'.length); }, ); }); it('Pad typed value with zero if digit exceeds the first digit of time segment', () => { cy.get('@input') .type('9') .should('have.value', '09') .should('have.prop', 'selectionStart', '09'.length) .should('have.prop', 'selectionEnd', '09'.length) .type('9') .should('have.value', '09:09') .should('have.prop', 'selectionStart', '09:09'.length) .should('have.prop', 'selectionEnd', '09:09'.length) .type('9') .should('have.value', '09:09:09') .should('have.prop', 'selectionStart', '09:09:09'.length) .should('have.prop', 'selectionEnd', '09:09:09'.length); }); }); describe('HH:MM:SS.MSS', () => { beforeEach(() => { cy.visit(`/${DemoPath.Time}/API?mode=HH:MM:SS.MSS`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .clear() .as('input'); }); describe('Typing new character overwrite character after cursor', () => { it('new character is different from the next one', () => { cy.get('@input') .type('235959999') .type('{moveToStart}') .type('0') .should('have.value', '03:59:59.999') .should('have.prop', 'selectionStart', '0'.length) .should('have.prop', 'selectionEnd', '0'.length) .type('000') .should('have.value', '00:00:59.999') .should('have.prop', 'selectionStart', '00:00:'.length) .should('have.prop', 'selectionEnd', '00:00:'.length) .type('00') .should('have.value', '00:00:00.999') .should('have.prop', 'selectionStart', '00:00:00.'.length) .should('have.prop', 'selectionEnd', '00:00:00.'.length) .type('0') .should('have.value', '00:00:00.099') .should('have.prop', 'selectionStart', '00:00:00.0'.length) .should('have.prop', 'selectionEnd', '00:00:00.0'.length); }); it('moves cursor behind next character if new character is the same with the next one', () => { cy.get('@input') .type('02:59:59.999') .type('{moveToStart}') .type('{rightArrow}'.repeat('02:59:59.'.length)) .type('9') .should('have.value', '02:59:59.999') .should('have.prop', 'selectionStart', '02:59:59.9'.length) .should('have.prop', 'selectionEnd', '02:59:59.9'.length); }); }); describe('Select range and press new digit', () => { it( '23:|59|:59.999 => Type 2 => 23:2|0:59.999', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('235959999') .should('have.value', '23:59:59.999') .realPress([ ...repeatKey('ArrowLeft', ':59.999'.length), 'Shift', ...repeatKey('ArrowLeft', '59'.length), ]); cy.get('@input') .type('2') .should('have.value', '23:20:59.999') .should('have.prop', 'selectionStart', '23:2'.length) .should('have.prop', 'selectionEnd', '23:2'.length); }, ); it( '23:|59|:59.999 => Type 0 => 23:0|0:59.999', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('235959999') .should('have.value', '23:59:59.999') .realPress([ ...repeatKey('ArrowLeft', ':59.999'.length), 'Shift', ...repeatKey('ArrowLeft', '59'.length), ]); cy.get('@input') .type('0') .should('have.value', '23:00:59.999') .should('have.prop', 'selectionStart', '23:0'.length) .should('have.prop', 'selectionEnd', '23:0'.length); }, ); }); it('Pad typed value with zero if digit exceeds the first digit of time segment', () => { cy.get('@input') .type('9') .should('have.value', '09') .should('have.prop', 'selectionStart', '09'.length) .should('have.prop', 'selectionEnd', '09'.length) .type('9') .should('have.value', '09:09') .should('have.prop', 'selectionStart', '09:09'.length) .should('have.prop', 'selectionEnd', '09:09'.length) .type('9') .should('have.value', '09:09:09') .should('have.prop', 'selectionStart', '09:09:09'.length) .should('have.prop', 'selectionEnd', '09:09:09'.length); }); describe('accepts time segment separators typed by user', () => { it('23 => Type : => 23:', () => { cy.get('@input') .type('23') .should('have.value', '23') .type(':') .should('have.value', '23:') .should('have.prop', 'selectionStart', '23:'.length) .should('have.prop', 'selectionEnd', '23:'.length); }); it('23:59 => Type : => 23:59:', () => { cy.get('@input') .type('2359') .should('have.value', '23:59') .type(':') .should('have.value', '23:59:') .should('have.prop', 'selectionStart', '23:59:'.length) .should('have.prop', 'selectionEnd', '23:59:'.length); }); it('23:59:59 => Type . => 23:59:59.', () => { cy.get('@input') .type('235959') .should('have.value', '23:59:59') .type('.') .should('have.value', '23:59:59.') .should('have.prop', 'selectionStart', '23:59:59.'.length) .should('have.prop', 'selectionEnd', '23:59:59.'.length); }); }); }); describe('HH', () => { describe('default segments', () => { beforeEach(() => { cy.visit(`/${DemoPath.Time}/API?mode=HH`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .clear() .as('input'); }); it('2| => type 5 => 2|', () => { cy.get('@input') .type('2') .should('have.value', '2') .should('have.prop', 'selectionStart', '2'.length) .should('have.prop', 'selectionEnd', '2'.length) .type('5') .should('have.value', '2') .should('have.prop', 'selectionStart', '2'.length) .should('have.prop', 'selectionEnd', '2'.length); }); it('2| => type 3 => 23|', () => { cy.get('@input') .type('2') .should('have.value', '2') .should('have.prop', 'selectionStart', '2'.length) .should('have.prop', 'selectionEnd', '2'.length) .type('3') .should('have.value', '23') .should('have.prop', 'selectionStart', '23'.length) .should('have.prop', 'selectionEnd', '23'.length); }); it('23| => type 5 => 23|', () => { cy.get('@input') .type('23') .should('have.value', '23') .should('have.prop', 'selectionStart', '23'.length) .should('have.prop', 'selectionEnd', '23'.length) .type('5') .should('have.value', '23') .should('have.prop', 'selectionStart', '23'.length) .should('have.prop', 'selectionEnd', '23'.length); }); }); describe('max hours 11', () => { beforeEach(() => { cy.visit(`/${DemoPath.Time}/API?mode=HH&timeSegmentMaxValues$=2`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .clear() .as('input'); }); it('type 2 => 02', () => { cy.get('@input') .type('2') .should('have.value', '02') .should('have.prop', 'selectionStart', '02'.length) .should('have.prop', 'selectionEnd', '02'.length); }); }); }); describe('MM:SS.MSS', () => { beforeEach(() => { cy.visit(`/${DemoPath.Time}/API?mode=MM:SS.MSS`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .clear() .as('input'); }); describe('Typing new character overwrite character after cursor', () => { it('new character is different from the next one', () => { cy.get('@input') .type('5959999') .type('{moveToStart}') .type('0') .should('have.value', '09:59.999') .should('have.prop', 'selectionStart', '0'.length) .should('have.prop', 'selectionEnd', '0'.length) .type('000') .should('have.value', '00:00.999') .should('have.prop', 'selectionStart', '00:00.'.length) .should('have.prop', 'selectionEnd', '00:00.'.length) .type('00') .should('have.value', '00:00.009') .should('have.prop', 'selectionStart', '00:00.00'.length) .should('have.prop', 'selectionEnd', '00:00.00'.length); }); it('moves cursor behind next character if new character is the same with the next one', () => { cy.get('@input') .type('59:59.999') .type('{moveToStart}') .type('{rightArrow}'.repeat('59:59.'.length)) .type('9') .should('have.value', '59:59.999') .should('have.prop', 'selectionStart', '59:59.9'.length) .should('have.prop', 'selectionEnd', '59:59.9'.length); }); }); it('Pad typed value with zero if digit exceeds the first digit of time segment', () => { cy.get('@input') .type('6') .should('have.value', '06') .should('have.prop', 'selectionStart', '06'.length) .should('have.prop', 'selectionEnd', '06'.length) .type('6') .should('have.value', '06:06') .should('have.prop', 'selectionStart', '06:06'.length) .should('have.prop', 'selectionEnd', '06:06'.length) .type('999') .should('have.value', '06:06.999') .should('have.prop', 'selectionStart', '06:06.999'.length) .should('have.prop', 'selectionEnd', '06:06.999'.length); }); describe('Select range and press new digit', () => { it( '|59|:59.999 => Type 2 => 2|0:59.999', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('5959999') .should('have.value', '59:59.999') .realPress([ ...repeatKey('ArrowLeft', ':59.999'.length), 'Shift', ...repeatKey('ArrowLeft', '59'.length), ]); cy.get('@input') .type('2') .should('have.value', '20:59.999') .should('have.prop', 'selectionStart', '2'.length) .should('have.prop', 'selectionEnd', '2'.length); }, ); it( '|59|:59.999 => Type 6 => 06:|59.999', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('5959999') .should('have.value', '59:59.999') .realPress([ ...repeatKey('ArrowLeft', ':59.999'.length), 'Shift', ...repeatKey('ArrowLeft', '59'.length), ]); cy.get('@input') .type('6') .should('have.value', '06:59.999') .should('have.prop', 'selectionStart', '06:'.length) .should('have.prop', 'selectionEnd', '06:'.length); }, ); }); describe('accepts time segment separators typed by user', () => { it('59 => Type : => 59:', () => { cy.get('@input') .type('59') .should('have.value', '59') .type(':') .should('have.value', '59:') .should('have.prop', 'selectionStart', '59:'.length) .should('have.prop', 'selectionEnd', '59:'.length); }); it('59:59 => Type . => 59:59.', () => { cy.get('@input') .type('5959') .should('have.value', '59:59') .type('.') .should('have.value', '59:59.') .should('have.prop', 'selectionStart', '59:59.'.length) .should('have.prop', 'selectionEnd', '59:59.'.length); }); }); it('type 5959999 => 59:59.999', () => { cy.get('@input').type('5959999').should('have.value', '59:59.999'); }); }); describe('SS.MSS', () => { beforeEach(() => { cy.visit(`/${DemoPath.Time}/API?mode=SS.MSS`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .clear() .as('input'); }); describe('Typing new character overwrite character after cursor', () => { it('new character is different from the next one', () => { cy.get('@input') .type('59999') .type('{moveToStart}') .type('0') .should('have.value', '09.999') .should('have.prop', 'selectionStart', '0'.length) .should('have.prop', 'selectionEnd', '0'.length) .type('0') .should('have.value', '00.999') .should('have.prop', 'selectionStart', '00.'.length) .should('have.prop', 'selectionEnd', '00.'.length) .type('00') .should('have.value', '00.009') .should('have.prop', 'selectionStart', '00.00'.length) .should('have.prop', 'selectionEnd', '00.00'.length); }); it('moves cursor behind next character if new character is the same with the next one', () => { cy.get('@input') .type('59.999') .type('{moveToStart}') .type('{rightArrow}'.repeat('59.'.length)) .type('9') .should('have.value', '59.999') .should('have.prop', 'selectionStart', '59.9'.length) .should('have.prop', 'selectionEnd', '59.9'.length); }); }); it('Pad typed value with zero if digit exceeds the first digit of time segment', () => { cy.get('@input') .type('6') .should('have.value', '06') .should('have.prop', 'selectionStart', '06'.length) .should('have.prop', 'selectionEnd', '06'.length) .type('999') .should('have.value', '06.999') .should('have.prop', 'selectionStart', '06.999'.length) .should('have.prop', 'selectionEnd', '06:999'.length); }); describe('Select range and press new digit', () => { it('|59|.999 => Type 2 => 2|0.999', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('59999') .should('have.value', '59.999') .realPress([ ...repeatKey('ArrowLeft', '.999'.length), 'Shift', ...repeatKey('ArrowLeft', '59'.length), ]); cy.get('@input') .type('2') .should('have.value', '20.999') .should('have.prop', 'selectionStart', '2'.length) .should('have.prop', 'selectionEnd', '2'.length); }); it('|59|.999 => Type 6 => 06.|999', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('59999') .should('have.value', '59.999') .realPress([ ...repeatKey('ArrowLeft', '.999'.length), 'Shift', ...repeatKey('ArrowLeft', '59'.length), ]); cy.get('@input') .type('6') .should('have.value', '06.999') .should('have.prop', 'selectionStart', '06.'.length) .should('have.prop', 'selectionEnd', '06.'.length); }); }); describe('accepts time segment separators typed by user', () => { it('59 => Type . => 59.', () => { cy.get('@input') .type('59') .should('have.value', '59') .type('.') .should('have.value', '59.') .should('have.prop', 'selectionStart', '59.'.length) .should('have.prop', 'selectionEnd', '59.'.length); }); }); it('type 59999 => 59.999', () => { cy.get('@input').type('59999').should('have.value', '59.999'); }); }); describe('MM:SS', () => { beforeEach(() => { cy.visit(`/${DemoPath.Time}/API?mode=MM:SS`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .clear() .as('input'); }); describe('Typing new character overwrite character after cursor', () => { it('new character is different from the next one', () => { cy.get('@input') .type('59:59') .type('{moveToStart}') .type('0') .should('have.value', '09:59') .should('have.prop', 'selectionStart', '0'.length) .should('have.prop', 'selectionEnd', '0'.length) .type('0') .should('have.value', '00:59') .should('have.prop', 'selectionStart', '00:'.length) .should('have.prop', 'selectionEnd', '00:'.length) .type('00') .should('have.value', '00:00') .should('have.prop', 'selectionStart', '00:00'.length) .should('have.prop', 'selectionEnd', '00:00'.length); }); it('moves cursor behind next character if new character is the same with the next one', () => { cy.get('@input') .type('59:59') .type('{moveToStart}') .type('{rightArrow}'.repeat('59:'.length)) .type('59') .should('have.value', '59:59') .should('have.prop', 'selectionStart', '59:59'.length) .should('have.prop', 'selectionEnd', '59:59'.length); }); }); it('Pad typed value with zero if digit exceeds the first digit of time segment', () => { cy.get('@input') .type('6') .should('have.value', '06') .should('have.prop', 'selectionStart', '06'.length) .should('have.prop', 'selectionEnd', '06'.length) .type('9') .should('have.value', '06:09') .should('have.prop', 'selectionStart', '06:09'.length) .should('have.prop', 'selectionEnd', '06:09'.length); }); describe('Select range and press new digit', () => { it('|59|:59 => Type 2 => 2|0:59', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('5959') .should('have.value', '59:59') .realPress([ ...repeatKey('ArrowLeft', ':59'.length), 'Shift', ...repeatKey('ArrowLeft', '59'.length), ]); cy.get('@input') .type('2') .should('have.value', '20:59') .should('have.prop', 'selectionStart', '2'.length) .should('have.prop', 'selectionEnd', '2'.length); }); it('|59|:59 => Type 6 => 06:|59', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('5959') .should('have.value', '59:59') .realPress([ ...repeatKey('ArrowLeft', ':59'.length), 'Shift', ...repeatKey('ArrowLeft', '59'.length), ]); cy.get('@input') .type('6') .should('have.value', '06:59') .should('have.prop', 'selectionStart', '06:'.length) .should('have.prop', 'selectionEnd', '06:'.length); }); }); describe('accepts time segment separators typed by user', () => { it('59 => Type : => 59:', () => { cy.get('@input') .type('59') .should('have.value', '59') .type(':') .should('have.value', '59:') .should('have.prop', 'selectionStart', '59:'.length) .should('have.prop', 'selectionEnd', '59:'.length); }); }); it('type 9999 => 09:09', () => { cy.get('@input').type('9999').should('have.value', '09:09'); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/time/time-segment-max-values.cy.ts ================================================ import {DemoPath} from '@demo/constants'; import {range} from '../../utils'; describe('Time | [timeSegmentMaxValues] property', () => { describe('{hours: 5, minutes: 5, seconds: 5, milliseconds: 5}', () => { beforeEach(() => { cy.visit(`/${DemoPath.Time}/API?mode=HH:MM&timeSegmentMaxValues$=3`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .as('textfield'); }); describe('hour segment', () => { describe('rejects all digits > 5 as the 1st digit (and does not pad them)', () => { range(6, 9).forEach((digit) => { it(`Empty textfield => type ${digit} => empty textfield`, () => { cy.get('@textfield') .type(`${digit}`) .should('have.value', '') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); }); }); describe('pads the first hour digit with zero for typed digits <=5', () => { range(1, 5).forEach((digit) => { it(`Empty textfield => type ${digit} => 0${digit}`, () => { cy.get('@textfield') .type(`${digit}`) .should('have.value', `0${digit}`) .should('have.prop', 'selectionStart', 2) .should('have.prop', 'selectionEnd', 2); }); }); }); it('accepts 0 as the 1st hour digit', () => { cy.get('@textfield') .type('0') .should('have.value', '0') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); describe('rejects all digits > 5 as the 2nd digit', () => { range(6, 9).forEach((digit) => { it(`Empty textfield => type 0${digit} => 0`, () => { cy.get('@textfield') .type(`0${digit}`) .should('have.value', '0') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); }); }); describe('accepts all digits <=5 as the 2nd hour digit', () => { range(0, 5).forEach((digit) => { it(`Empty textfield => type 0${digit} => 0${digit}`, () => { cy.get('@textfield') .type(`0${digit}`) .should('have.value', `0${digit}`) .should('have.prop', 'selectionStart', 2) .should('have.prop', 'selectionEnd', 2); }); }); }); it('05:05 => type 1 => 00:01', () => { cy.get('@textfield') .type('0505') .should('have.value', '05:05') .type('{moveToStart}') .type('1') .should('have.value', '01:05') .should('have.prop', 'selectionStart', '01:'.length) .should('have.prop', 'selectionEnd', '01:'.length); }); }); describe('minute segment', () => { describe('rejects all digits > 5 as the 1st minute digit (and does not pad them)', () => { range(6, 9).forEach((digit) => { it(`00 => type ${digit} => 00`, () => { cy.get('@textfield') .type(`00${digit}`) .should('have.value', '00') .should('have.prop', 'selectionStart', 2) .should('have.prop', 'selectionEnd', 2); }); }); }); describe('pads the first minute digit with zero for typed digits <=5', () => { range(1, 5).forEach((digit) => { it(`00 => type ${digit} => 0${digit}`, () => { cy.get('@textfield') .type(`00${digit}`) .should('have.value', `00:0${digit}`) .should('have.prop', 'selectionStart', `00:0${digit}`.length) .should('have.prop', 'selectionEnd', `00:0${digit}`.length); }); }); }); it('accepts 0 as the 1st minute digit', () => { cy.get('@textfield') .type('000') .should('have.value', '00:0') .should('have.prop', 'selectionStart', '00:0'.length) .should('have.prop', 'selectionEnd', '00:0'.length); }); describe('rejects all digits > 5 as the 2nd minute digit', () => { range(6, 9).forEach((digit) => { it(`00 => type 0${digit} => 0`, () => { cy.get('@textfield') .type(`000${digit}`) .should('have.value', '00:0') .should('have.prop', 'selectionStart', '00:0'.length) .should('have.prop', 'selectionEnd', '00:0'.length); }); }); }); describe('accepts all digits <=5 as the 2nd hour digit', () => { range(0, 5).forEach((digit) => { it(`00:0 => type 0${digit} => 0${digit}`, () => { cy.get('@textfield') .type(`000${digit}`) .should('have.value', `00:0${digit}`) .should('have.prop', 'selectionStart', `00:0${digit}`.length) .should('have.prop', 'selectionEnd', `00:0${digit}`.length); }); }); }); it('00:|02 => type 1 => 00:01', () => { cy.get('@textfield') .type('0002') .should('have.value', '00:02') .type('{leftArrow}'.repeat(2)) .should('have.prop', 'selectionStart', '00:'.length) .should('have.prop', 'selectionEnd', '00:'.length) .type('1') .should('have.value', '00:01') .should('have.prop', 'selectionStart', '00:01'.length) .should('have.prop', 'selectionEnd', '00:01'.length); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/kit/time/time-step.cy.ts ================================================ import {DemoPath} from '@demo/constants'; import {BROWSER_SUPPORTS_REAL_EVENTS} from 'projects/demo-integrations/src/support/constants'; import {withCaretLabel} from '../../utils'; describe('Time', () => { describe('Mode', () => { describe('HH:MM', () => { describe('step = 1', () => { beforeEach(() => { cy.visit(`/${DemoPath.Time}/API?mode=HH:MM&step=1`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .clear() .as('input'); }); it('empty field => {upArrow} => 01', () => { cy.get('@input').type('{upArrow}').should('have.value', '01'); }); it('type 2 + type {upArrow} => 21', () => { cy.get('@input').type('2{upArrow}').should('have.value', '21'); }); it('{downArrow} => |23 => 23| => type {upArrow} => 23:01', () => { cy.get('@input') .type('{downArrow}') .should('have.value', '23') .should('have.a.prop', 'selectionStart', 0) .should('have.a.prop', 'selectionEnd', 0) .type('{rightArrow}'.repeat(2)) .type(':{upArrow}') .should('have.value', '23:01'); }); it('{downArrow} => |23 => 23| => type :{downArrow} => 23:59', () => { cy.get('@input') .type('{downArrow}') .should('have.value', '23') .should('have.a.prop', 'selectionStart', 0) .should('have.a.prop', 'selectionEnd', 0) .type('{rightArrow}'.repeat(2)) .type(':{downArrow}') .should('have.value', '23:59'); }); it('{upArrow}*24 => 00', () => { cy.get('@input') .type('{upArrow}'.repeat(24)) .should('have.value', '00'); }); it('type {upArrow}*12 => |12 => 1|2 => type {upArrow} => 1|3 => 13| => type {downArrow} => 12|', () => { cy.get('@input') .type('{upArrow}'.repeat(12)) .should('have.value', '12') .should('have.a.prop', 'selectionStart', 0) .should('have.a.prop', 'selectionEnd', 0) .type('{rightArrow}') .should('have.a.prop', 'selectionStart', 1) .should('have.a.prop', 'selectionEnd', 1) .type('{upArrow}') .should('have.value', '13') .should('have.a.prop', 'selectionStart', 1) .should('have.a.prop', 'selectionEnd', 1) .type('{rightArrow}') .should('have.a.prop', 'selectionStart', 2) .should('have.a.prop', 'selectionEnd', 2) .type('{downArrow}') .should('have.value', '12') .should('have.a.prop', 'selectionStart', 2) .should('have.a.prop', 'selectionEnd', 2); }); it('type 12:{upArrow}{rightArrow}{downArrow}{rightArrow}{downArrow} => 12:59', () => { cy.get('@input') .type('12:{upArrow}') .type('{rightArrow}{downArrow}'.repeat(2)) .should('have.value', '12:59'); }); it('12|:0 => {upArrow} => 13|:0', () => { cy.get('@input') .type('12:0') .type('{leftArrow}'.repeat(2)) .type('{upArrow}') .should('have.value', '13:0') .should('have.a.prop', 'selectionStart', 2) .should('have.a.prop', 'selectionEnd', 2); }); }); describe('step = 0', () => { beforeEach(() => { cy.visit(`/${DemoPath.Time}/API?mode=HH:MM&step=0`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .clear() .as('input'); }); it('should be disabled', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input').type('1212').realPress('ArrowUp'); cy.get('@input') .should('have.a.prop', 'selectionStart', 0) .should('have.a.prop', 'selectionEnd', 0) .realPress('ArrowDown'); cy.get('@input') .should('have.a.prop', 'selectionStart', '12:12'.length) .should('have.a.prop', 'selectionEnd', '12:12'.length); }); }); }); describe('HH:MM AA', () => { describe('step = 1', () => { beforeEach(() => { cy.visit(`/${DemoPath.Time}/API?mode=HH:MM%20AA&step=1`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .clear() .as('textfield'); }); describe('time segment digits stepping', () => { [ {value: '12:34 AM', caretIndex: 0, newValue: '01:34 AM'}, {value: '12:34 AM', caretIndex: '1'.length, newValue: '01:34 AM'}, { value: '12:34 AM', caretIndex: '12'.length, newValue: '01:34 AM', }, { value: '12:34 AM', caretIndex: '12:'.length, newValue: '12:35 AM', }, { value: '12:34 AM', caretIndex: '12:3'.length, newValue: '12:35 AM', }, { value: '12:34 AM', caretIndex: '12:34'.length, newValue: '12:35 AM', }, ].forEach(({value, caretIndex, newValue}) => { it(`${withCaretLabel(value, caretIndex)} --- ↑ --- ${withCaretLabel(newValue, caretIndex)}`, () => { cy.get('@textfield') .type(value) .type(`{moveToStart}${'{rightArrow}'.repeat(caretIndex)}`) .type('{upArrow}') .should('have.value', newValue) .should('have.a.prop', 'selectionStart', caretIndex) .should('have.a.prop', 'selectionEnd', caretIndex); }); }); [ {value: '12:34 PM', caretIndex: 0, newValue: '11:34 PM'}, {value: '12:34 PM', caretIndex: '1'.length, newValue: '11:34 PM'}, { value: '12:34 PM', caretIndex: '12'.length, newValue: '11:34 PM', }, { value: '12:34 PM', caretIndex: '12:'.length, newValue: '12:33 PM', }, { value: '12:34 PM', caretIndex: '12:3'.length, newValue: '12:33 PM', }, { value: '12:34 PM', caretIndex: '12:34'.length, newValue: '12:33 PM', }, ].forEach(({value, caretIndex, newValue}) => { it(`${withCaretLabel(value, caretIndex)} --- ↓ --- ${withCaretLabel(newValue, caretIndex)}`, () => { cy.get('@textfield') .type(value) .type(`{moveToStart}${'{rightArrow}'.repeat(caretIndex)}`) .type('{downArrow}') .should('have.value', newValue) .should('have.a.prop', 'selectionStart', caretIndex) .should('have.a.prop', 'selectionEnd', caretIndex); }); }); }); describe('meridiem switching', () => { [ { value: '12:34 AM', caretIndex: '12:34 '.length, newValue: '12:34 PM', }, { value: '12:34 AM', caretIndex: '12:34 A'.length, newValue: '12:34 PM', }, { value: '12:34 AM', caretIndex: '12:34 AM'.length, newValue: '12:34 PM', }, ].forEach(({value, caretIndex, newValue}) => { it(`${withCaretLabel(value, caretIndex)} --- ↑ --- ${withCaretLabel(newValue, caretIndex)}`, () => { cy.get('@textfield') .type(value) .type(`{moveToStart}${'{rightArrow}'.repeat(caretIndex)}`) .type('{upArrow}') .should('have.value', newValue) .should('have.a.prop', 'selectionStart', caretIndex) .should('have.a.prop', 'selectionEnd', caretIndex); }); }); [ { value: '12:34 PM', caretIndex: '12:34 '.length, newValue: '12:34 AM', }, { value: '12:34 PM', caretIndex: '12:34 P'.length, newValue: '12:34 AM', }, { value: '12:34 PM', caretIndex: '12:34 PM'.length, newValue: '12:34 AM', }, ].forEach(({value, caretIndex, newValue}) => { it(`${withCaretLabel(value, caretIndex)} --- ↓ --- ${withCaretLabel(newValue, caretIndex)}`, () => { cy.get('@textfield') .type(value) .type(`{moveToStart}${'{rightArrow}'.repeat(caretIndex)}`) .type('{downArrow}') .should('have.value', newValue) .should('have.a.prop', 'selectionStart', caretIndex) .should('have.a.prop', 'selectionEnd', caretIndex); }); }); }); }); }); describe('HH:MM:SS', () => { describe('step = 1', () => { beforeEach(() => { cy.visit(`/${DemoPath.Time}/API?mode=HH:MM:SS&step=1`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .clear() .as('input'); }); it('12|:0 => {upArrow} => 13|:0', () => { cy.get('@input') .type('12:0') .type('{leftArrow}'.repeat(2)) .type('{upArrow}') .should('have.value', '13:0') .should('have.a.prop', 'selectionStart', 2) .should('have.a.prop', 'selectionEnd', 2); }); it('12:34|:5 => {downArrow} => 12:33|:5 ', () => { cy.get('@input') .type('12:34:5') .type('{leftArrow}'.repeat(2)) .type('{downArrow}') .should('have.value', '12:33:5') .should('have.a.prop', 'selectionStart', 5) .should('have.a.prop', 'selectionEnd', 5); }); }); }); describe('HH:MM:SS.MSS', () => { describe('step = 1', () => { beforeEach(() => { cy.visit(`/${DemoPath.Time}/API?mode=HH:MM:SS.MSS&step=1`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .clear() .as('input'); }); it('correctly works for hour segments', () => { cy.get('@input') .type('{upArrow}') .should('have.value', '01') .type('{rightArrow}{upArrow}') .should('have.value', '02') .should('have.a.prop', 'selectionStart', 1) .should('have.a.prop', 'selectionEnd', 1) .type('{rightArrow}') .type('{downArrow}'.repeat(3)) .should('have.value', '23') .should('have.a.prop', 'selectionStart', 2) .should('have.a.prop', 'selectionEnd', 2); }); it('correctly works for minute segments', () => { cy.get('@input') .type('12:') .should('have.value', '12:') .should('have.a.prop', 'selectionStart', '12:'.length) .should('have.a.prop', 'selectionEnd', '12:'.length) .type('{upArrow}') .should('have.a.prop', 'selectionStart', '12:'.length) .should('have.a.prop', 'selectionEnd', '12:'.length) .should('have.value', '12:01') .type('{rightArrow}') .type('{downArrow}'.repeat(2)) .should('have.a.prop', 'selectionStart', '12:5'.length) .should('have.a.prop', 'selectionEnd', '12:5'.length) .should('have.value', '12:59') .type('{rightArrow}') .type('{downArrow}'.repeat(4)) .should('have.a.prop', 'selectionStart', '12:55'.length) .should('have.a.prop', 'selectionEnd', '12:55'.length) .should('have.value', '12:55'); }); it('correctly works for second segments', () => { cy.get('@input') .type('1234:') .should('have.value', '12:34:') .should('have.a.prop', 'selectionStart', '12:34:'.length) .should('have.a.prop', 'selectionEnd', '12:34:'.length) .type('{upArrow}'.repeat(5)) .should('have.a.prop', 'selectionStart', '12:34:'.length) .should('have.a.prop', 'selectionEnd', '12:34:'.length) .should('have.value', '12:34:05') .type('{rightArrow}') .type('{downArrow}'.repeat(8)) .should('have.a.prop', 'selectionStart', '12:34:5'.length) .should('have.a.prop', 'selectionEnd', '12:34:5'.length) .should('have.value', '12:34:57') .type('{rightArrow}') .type('{upArrow}'.repeat(3)) .should('have.a.prop', 'selectionStart', '12:34:00'.length) .should('have.a.prop', 'selectionEnd', '12:34:00'.length) .should('have.value', '12:34:00'); }); it('correctly works for millisecond segments', () => { cy.get('@input') .type('123456.') .should('have.value', '12:34:56.') .should('have.a.prop', 'selectionStart', '12:34:56.'.length) .should('have.a.prop', 'selectionEnd', '12:34:56.'.length) .type('{upArrow}'.repeat(23)) .should('have.value', '12:34:56.023') .should('have.a.prop', 'selectionStart', '12:34:56.'.length) .should('have.a.prop', 'selectionEnd', '12:34:56.'.length) .type('{rightArrow}{downArrow}') .should('have.value', '12:34:56.022') .should('have.a.prop', 'selectionStart', '12:34:56.0'.length) .should('have.a.prop', 'selectionEnd', '12:34:56.0'.length) .type('{rightArrow}') .type('{upArrow}'.repeat(3)) .should('have.value', '12:34:56.025') .should('have.a.prop', 'selectionStart', '12:34:56.02'.length) .should('have.a.prop', 'selectionEnd', '12:34:56.02'.length) .type('{rightArrow}') .type('{downArrow}'.repeat(29)) .should('have.value', '12:34:56.996') .should('have.a.prop', 'selectionStart', '12:34:56.996'.length) .should('have.a.prop', 'selectionEnd', '12:34:56.996'.length); }); }); describe('step = 10', () => { beforeEach(() => { cy.visit(`/${DemoPath.Time}/API?mode=HH:MM:SS.MSS&step=10`); cy.get('#demo-content input') .should('be.visible') .first() .focus() .clear() .as('input'); }); it('correctly works for millisecond segments', () => { cy.get('@input') .type('123456.') .should('have.value', '12:34:56.') .type('{upArrow}'.repeat(20)) .should('have.value', '12:34:56.200') .type('{downArrow}'.repeat(30)) .should('have.value', '12:34:56.900'); }); it('correctly works for each time segment', () => { cy.get('@input') .type('123456000') .should('have.value', '12:34:56.000') .should('have.a.prop', 'selectionStart', '12:34:56.000'.length) .should('have.a.prop', 'selectionEnd', '12:34:56.000'.length) .type('{downArrow}') .should('have.value', '12:34:56.990') .should('have.a.prop', 'selectionStart', '12:34:56.990'.length) .should('have.a.prop', 'selectionEnd', '12:34:56.990'.length) .type('{leftArrow}'.repeat(4)) .type('{downArrow}') .should('have.value', '12:34:46.990') .should('have.a.prop', 'selectionStart', '12:34:46'.length) .should('have.a.prop', 'selectionEnd', '12:34:46'.length) .type('{leftArrow}'.repeat(3)) .type('{upArrow}') .should('have.value', '12:44:46.990') .should('have.a.prop', 'selectionStart', '12:44'.length) .should('have.a.prop', 'selectionEnd', '12:44'.length) .type('{leftArrow}'.repeat(3)) .type('{upArrow}') .should('have.value', '22:44:46.990') .should('have.a.prop', 'selectionStart', '22'.length) .should('have.a.prop', 'selectionEnd', '22'.length); }); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/react/element-predicate.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('@maskito/react | Element Predicate', () => { describe('Sync predicate works', () => { beforeEach(() => { cy.visit(DemoPath.React); cy.get('#awesome-input-wrapper input.real-input') .scrollIntoView() .should('be.visible') .as('input'); }); it('rejects invalid characters', () => { cy.get('@input').type('abc12def').should('have.value', '12'); }); it('accepts valid input', () => { cy.get('@input').type('12.09.2023').should('have.value', '12.09.2023'); }); it('automatically adds fixed characters', () => { cy.get('@input').type('12092023').should('have.value', '12.09.2023'); }); it('automatically pads day / month segments with zeroes for large digits', () => { cy.get('@input').type('992023').should('have.value', '09.09.2023'); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/recipes/card/card.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Card', () => { describe('Card number input', () => { beforeEach(() => { cy.visit(DemoPath.Card); cy.get('#card input').should('be.visible').first().focus().as('input'); }); it('accepts 16-digits card number', () => { cy.get('@input') .type('1234567890123456') .should('have.value', '1234 5678 9012 3456'); }); it('accepts 18-digits card number', () => { cy.get('@input') .type('123456789012345678') .should('have.value', '1234 5678 9012 3456 78'); }); it('accepts 19-digits card number', () => { cy.get('@input') .type('1234567890123456789') .should('have.value', '1234 5678 9012 3456 789'); }); it('does not accept more than 19-digits', () => { cy.get('@input') .type('0'.repeat(25)) .should('have.value', '0000 0000 0000 0000 000'); }); }); describe('Expiration date', () => { beforeEach(() => { cy.visit(DemoPath.Card); cy.get('#card input').should('be.visible').eq(2).focus().as('input'); }); it('input 321 => 03/21', () => { cy.get('@input') .type('321') .should('have.value', '03/21') .should('have.prop', 'selectionStart', '03/21'.length) .should('have.prop', 'selectionEnd', '03/21'.length); }); }); describe('CVV', () => { beforeEach(() => { cy.visit(DemoPath.Card); cy.get('#card input').should('be.visible').eq(4).focus().as('input'); }); it('input 4321 => 432', () => { cy.get('@input') .type('4321') .should('have.value', '432') .should('have.prop', 'selectionStart', '432'.length) .should('have.prop', 'selectionEnd', '432'.length); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/recipes/content-editable/multi-line.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('ContentEditable | Multi-line support', () => { describe('Deletion', () => { beforeEach(() => { cy.visit(DemoPath.ContentEditable); cy.get('#multi-line [contenteditable]') .should('be.visible') .first() .focus() .as('element'); }); it('Select all + delete => Empty', () => { cy.get('@element').type('{selectAll}{del}').should('have.text', ''); }); it('Select all + Backspace => Empty', () => { cy.get('@element').type('{selectAll}{backspace}').should('have.text', ''); }); it('Long multi-line text', () => { cy.get('@element') .clear() .type('a b{enter}cd ef{enter}aa11bb') .should('have.text', 'a b\ncd ef\naabb') .type('{backspace}'.repeat(5)) .should('have.text', 'a b\ncd ef') .type('{backspace}'.repeat(2)) .should('have.text', 'a b\ncd ') .type('{backspace}'.repeat(4)) .should('have.text', 'a b'); }); }); describe('Rejects invalid symbols on EVERY line', () => { beforeEach(() => { cy.visit(DemoPath.ContentEditable); cy.get('#multi-line [contenteditable]') .should('be.visible') .first() .focus() .clear() .should('have.text', '') .as('element'); }); const tests = [ // [Typed value, Masked value] ['abc123 def', 'abc def'], ['abc123 def{enter}1a2b3c 4d', 'abc def\nabc d'], ['a{enter}b{enter}{enter}aa11bb', 'a\nb\n\naabb'], ] as const; tests.forEach(([typed, masked]) => { it(`Type ${typed} => ${masked}`, () => { cy.get('@element').type(typed).should('have.text', masked); }); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/recipes/content-editable/single-line-time-mask.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('ContentEditable | With Time mask', () => { beforeEach(() => { cy.visit(DemoPath.ContentEditable); cy.get('#time [contenteditable]') .should('be.visible') .first() .clear() .focus() .should('have.value', '') .as('element'); }); describe('basic typing (1 character per keydown)', () => { const tests = [ // [Typed value, Masked value] ['1', '1'], ['1.', '1'], ['12', '12'], ['12:', '12:'], ['9', '09'], ['99', '09:09'], ['123', '12:3'], ['128', '12:08'], ['25', '2'], ] as const; tests.forEach(([typed, masked]) => { it(`Type ${typed} => ${masked}`, () => { cy.get('@element').type(typed).should('have.text', masked); }); }); }); describe('basic deletion via backspace', () => { const tests = [ // [initialValue, n-times backspace pressed, result] ['23:59', 1, '23:5'], ['23:59', 2, '23'], ['23:59', 3, '2'], ['23:59', 4, ''], ] as const; tests.forEach(([initialValue, n, result]) => { it(`${initialValue} => Backspace x${n} => ${result}`, () => { cy.get('@element') .type(initialValue) .type('{backspace}'.repeat(n)) .should('have.text', result); }); }); }); describe('basic deletion via delete', () => { const tests = [ // [initialValue, n-times backspace pressed, result] ['23:59', 1, '03:59'], ['23:59', 2, '00:59'], ['23:59', 3, '00:59'], ['23:59', 4, '00:09'], ['23:59', 5, '00:0'], ] as const; tests.forEach(([initialValue, n, result]) => { it(`${initialValue} => Move cursor to start => Delete x${n} => ${result}`, () => { cy.get('@element') .type(initialValue) .type('{moveToStart}') .type('{del}'.repeat(n)) .should('have.text', result); }); }); }); it('12:|36 => 12:09|', () => { cy.get('@element') .type('1236') .should('have.text', '12:36') .type('{leftArrow}'.repeat(2)) .type('9') .should('have.text', '12:09'); }); }); ================================================ FILE: projects/demo-integrations/src/tests/recipes/network-address/ipv4.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Network Address | IPv4', () => { beforeEach(() => { cy.visit(DemoPath.NetworkAddress); cy.get('#ipv4 input') .should('be.visible') .first() .should('have.value', '') .focus() .as('input'); }); describe('valid', () => { it('accepts full IPv4 value with separators inserted', () => { cy.get('@input').type('192168001001').should('have.value', '192.168.001.001'); }); it('accepts paste of value without separators', () => { cy.get('@input') .paste('192168001001') .should('have.value', '192.168.001.001'); }); it('accepts paste of value with separators', () => { cy.get('@input').paste('10.0.0.1').should('have.value', '10.0.0.1'); }); it('accepts paste of value with partial separators', () => { cy.get('@input') .paste('192.168001001') .should('have.value', '192.168.001.001'); }); it('accepts paste of long value and truncates', () => { cy.get('@input') .paste('192.168.1.01.255.255') .should('have.value', '192.168.1.01'); }); it('inserts separator after third digit in octet', () => { cy.get('@input').type('1921').should('have.value', '192.1'); }); it('clamps octet value to 255', () => { cy.get('@input').type('999').should('have.value', '255'); }); it('clamps later octets to 255', () => { cy.get('@input').type('192999001001').should('have.value', '192.255.001.001'); }); }); describe('invalid', () => { it('ignores non-digit characters', () => { cy.get('@input').type('1a2b3c4').should('have.value', '123.4'); }); it('ignores separators without enough digits', () => { cy.get('@input').type('..').should('have.value', ''); }); it('ignores separators mixed with too few digits', () => { cy.get('@input').type('1..').should('have.value', '1.'); }); it('does not accept more than 4 octets', () => { cy.get('@input').type('1'.repeat(20)).should('have.value', '111.111.111.111'); }); }); describe('editing', () => { it('backspace deletes last character', () => { cy.get('@input') .type('192.168') .type('{backspace}') .should('have.value', '192.16'); }); it('backspace deletes trailing separator', () => { cy.get('@input').type('192.').type('{backspace}').should('have.value', '192'); }); it('delete removes character in the middle', () => { cy.get('@input') .type('192168') .type('{leftArrow}'.repeat(2)) .type('{del}') .should('have.value', '192.18'); }); it('prevents deletion of separator character', () => { cy.get('@input') .type('192168') .type('{leftArrow}'.repeat(4)) .type('{del}') .should('have.value', '192.168'); }); it('text selection overwrite', () => { cy.get('@input') .type('192.168') .type('{selectall}10') .should('have.value', '10'); }); it('prevents insertion when full', () => { cy.get('@input') .type('192168001001') .should('have.value', '192.168.001.001') .type('5') .should('have.value', '192.168.001.001'); }); }); describe('partially omitted separators', () => { it('3 digits + 1 digit & separator + 3 digits + 3 digits', () => { cy.get('@input').paste('1921.123123').should('have.value', '192.1.123.123'); }); it('3 digits + 3 digits + 1 digit & separator + 3 digits', () => { cy.get('@input').paste('1921681.001').should('have.value', '192.168.1.001'); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/recipes/network-address/ipv6.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Network Address | IPv6', () => { beforeEach(() => { cy.visit(DemoPath.NetworkAddress); cy.get('#ipv6 input') .should('be.visible') .first() .should('have.value', '') .focus() .as('input'); }); describe('valid', () => { it('accepts full IPv6 value and normalizes to lowercase', () => { cy.get('@input') .type('20010DB885A3000000008A2E03707334') .should('have.value', '2001:0db8:85a3:0000:0000:8a2e:0370:7334'); }); it('accepts paste of value with separators', () => { cy.get('@input') .paste('2001:0db8:0000:0000:0000:ff00:0042:8329') .should('have.value', '2001:0db8:0000:0000:0000:ff00:0042:8329'); }); it('accepts paste of value without separators', () => { cy.get('@input') .paste('20010db8000000000000ff0000428329') .should('have.value', '2001:0db8:0000:0000:0000:ff00:0042:8329'); }); it('accepts paste of long value and truncates', () => { cy.get('@input') .paste('2001:0db8:0000:0000:0000:ff00:0042:8329:aaaa:aaaa') .should('have.value', '2001:0db8:0000:0000:0000:ff00:0042:8329'); }); it('inserts separators after 4 hex digits', () => { cy.get('@input').type('abcd1').should('have.value', 'abcd:1'); }); it('accepts mixed-case hex digits', () => { cy.get('@input').type('ABcdEF12').should('have.value', 'abcd:ef12'); }); }); describe('invalid', () => { it('ignores non-hex characters', () => { cy.get('@input').type('abg1!2Z3').should('have.value', 'ab12:3'); }); it('ignores separators without enough hex digits', () => { cy.get('@input').type('::::').should('have.value', ''); }); it('ignores separators mixed with too few hex digits', () => { cy.get('@input').type('ab::').should('have.value', 'ab'); }); it('does not accept more than 32 hex digits', () => { cy.get('@input') .type('f'.repeat(40)) .should('have.value', 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff'); }); }); describe('editing', () => { it('backspace deletes last character', () => { cy.get('@input') .type('2001:0db8') .type('{backspace}') .should('have.value', '2001:0db'); }); it('backspace deletes trailing separator', () => { cy.get('@input') .type('2001:') .type('{backspace}') .should('have.value', '2001'); }); it('delete removes hex character in the middle', () => { cy.get('@input') .type('20010db8') .type('{leftArrow}'.repeat(3)) .type('{del}') .should('have.value', '2001:0b8'); }); it('prevents delete of separator character', () => { cy.get('@input') .type('20010db8') .type('{leftArrow}'.repeat(5)) .type('{del}') .should('have.value', '2001:0db8'); }); it('text selection overwrite', () => { cy.get('@input') .type('2001:0db8') .type('{selectall}1111') .should('have.value', '1111'); }); it('prevents insertion when full', () => { cy.get('@input') .type('1aaA2bBb3ccc4dDd5eee6fFF77778888') .should('have.value', '1aaa:2bbb:3ccc:4ddd:5eee:6fff:7777:8888') .type('a') .should('have.value', '1aaa:2bbb:3ccc:4ddd:5eee:6fff:7777:8888'); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/recipes/network-address/mac.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Network Address | MAC', () => { beforeEach(() => { cy.visit(DemoPath.NetworkAddress); cy.get('#mac input') .should('be.visible') .first() .should('have.value', '') .focus() .as('input'); }); describe('valid', () => { it('accepts full MAC value and normalizes to uppercase', () => { cy.get('@input') .type('aabbccddeeff') .should('have.value', 'AA:BB:CC:DD:EE:FF'); }); it('accepts paste of value with separators', () => { cy.get('@input') .paste('00:1A:2B:3C:4D:5E') .should('have.value', '00:1A:2B:3C:4D:5E'); }); it('accepts paste of value without separators', () => { cy.get('@input') .paste('aabbccddeeff') .should('have.value', 'AA:BB:CC:DD:EE:FF'); }); it('accepts paste of long value and truncates', () => { cy.get('@input') .paste('00:1A:2B:3C:4D:5E:FF:FF') .should('have.value', '00:1A:2B:3C:4D:5E'); }); it('inserts separator after second hex digit', () => { cy.get('@input').type('aab').should('have.value', 'AA:B'); }); it('accepts mixed-case hex digits', () => { cy.get('@input').type('aBcD').should('have.value', 'AB:CD'); }); }); describe('invalid', () => { it('ignores non-hex characters', () => { cy.get('@input').type('abg1!2Z3').should('have.value', 'AB:12:3'); }); it('ignores separators without enough hex digits', () => { cy.get('@input').type('::::').should('have.value', ''); }); it('ignores consecutive separators after valid input', () => { cy.get('@input').type('aa::').should('have.value', 'AA:'); }); it('does not accept more than 12 hex digits', () => { cy.get('@input') .type('f'.repeat(20)) .should('have.value', 'FF:FF:FF:FF:FF:FF'); }); }); describe('editing', () => { it('backspace deletes last character', () => { cy.get('@input') .type('aabbcc') .type('{backspace}') .should('have.value', 'AA:BB:C'); }); it('backspace deletes trailing separator', () => { cy.get('@input').type('aa:').type('{backspace}').should('have.value', 'AA'); }); it('delete removes hex character in the middle', () => { cy.get('@input') .type('aabbcc') .type('{leftArrow}'.repeat(4)) .type('{del}') .should('have.value', 'AA:BC:C'); }); it('prevents delete of separator character', () => { cy.get('@input') .type('aabbcc') .type('{leftArrow}'.repeat(3)) .type('{del}') .should('have.value', 'AA:BB:CC'); }); it('text selection overwrite', () => { cy.get('@input') .type('aabbcc') .type('{selectall}1122') .should('have.value', '11:22'); }); it('prevents insertion when full', () => { cy.get('@input') .type('aabbccddeeff') .should('have.value', 'AA:BB:CC:DD:EE:FF') .type('a') .should('have.value', 'AA:BB:CC:DD:EE:FF'); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/recipes/phone/phone.cy.ts ================================================ import {DemoPath} from '@demo/constants'; import {BROWSER_SUPPORTS_REAL_EVENTS} from '../../../support/constants'; import {repeatKey} from '../../utils'; describe('Phone', () => { beforeEach(() => { cy.visit(DemoPath.Phone); cy.get('#kz input') .should('be.visible') .first() .should('have.value', '') .focus() .as('input'); }); describe('basic typing (1 character per keydown)', () => { const tests = [ // [Typed value, Masked value] ['9', '+7 (9'], ['91', '+7 (91'], ['912', '+7 (912'], ['9123', '+7 (912) 3'], ['91234', '+7 (912) 34'], ['912345', '+7 (912) 345'], ['9123456', '+7 (912) 345-6'], ['91234567', '+7 (912) 345-67'], ['912345678', '+7 (912) 345-67-8'], ['9123456789', '+7 (912) 345-67-89'], ['91234567890', '+7 (912) 345-67-89'], ['912345678900000000', '+7 (912) 345-67-89'], ] as const; tests.forEach(([typedValue, maskedValue]) => { it(`Typing "${typedValue}" => "${maskedValue}"`, () => { cy.get('@input') .type(typedValue) .should('have.value', maskedValue) .should('have.prop', 'selectionStart', maskedValue.length) .should('have.prop', 'selectionEnd', maskedValue.length); }); }); }); describe('basic erasing (caret is placed after the last value)', () => { beforeEach(() => { cy.get('@input').type('9123456789'); }); const tests = [ // [How many times "Backspace"-key was pressed, Masked value] [1, '+7 (912) 345-67-8'], [2, '+7 (912) 345-67'], [3, '+7 (912) 345-6'], [4, '+7 (912) 345'], [5, '+7 (912) 34'], [6, '+7 (912) 3'], [7, '+7 (912'], [8, '+7 (91'], [9, '+7 (9'], [10, '+7 '], ] as const; tests.forEach(([n, maskedValue]) => { it(`Backspace x${n} => "${maskedValue}"`, () => { cy.get('@input') .type('{backspace}'.repeat(n)) .should('have.value', maskedValue) .should('have.prop', 'selectionStart', maskedValue.length) .should('have.prop', 'selectionEnd', maskedValue.length); }); }); it('Delete => no value change && no caret index change', () => { cy.get('@input') .type('{del}') .should('have.value', '+7 (912) 345-67-89') .should('have.prop', 'selectionStart', '+7 (912) 345-67-89'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-67-89'.length); }); }); describe('Editing somewhere in the middle of a value (NOT the last character)', () => { beforeEach(() => { cy.get('@input').type('9123456789'); }); // "|"-symbol is the caret position it('+7 (912) 345-67-8|9 => Backspace + 0 => +7 (912) 345-67-0|9', () => { cy.get('@input') .focus() .type('{leftArrow}') .should('have.prop', 'selectionStart', '+7 (912) 345-67-8'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-67-8'.length) .type('{backspace}') .should('have.value', '+7 (912) 345-67-9') .should('have.prop', 'selectionStart', '+7 (912) 345-67-'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-67-'.length) .type('0') .should('have.value', '+7 (912) 345-67-09') .should('have.prop', 'selectionStart', '+7 (912) 345-67-0'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-67-0'.length); }); it('+7 (912) 345-67|-89 => Backspace + 0 => +7 (912) 345-60-|89', () => { cy.get('@input') .focus() .type('{leftArrow}{leftArrow}{leftArrow}') .should('have.prop', 'selectionStart', '+7 (912) 345-67'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-67'.length) .type('{backspace}') .should('have.value', '+7 (912) 345-68-9') .should('have.prop', 'selectionStart', '+7 (912) 345-6'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-6'.length) .type('0') .should('have.value', '+7 (912) 345-60-89') .should('have.prop', 'selectionStart', '+7 (912) 345-60-'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-60-'.length); }); it('+7 (912) 345-6|7-89 => Backspace + 0 => +7 (912) 345-0|7-89', () => { cy.get('@input') .focus() .type('{leftArrow}'.repeat('7-89'.length)) .should('have.prop', 'selectionStart', '+7 (912) 345-6'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-6'.length) .type('{backspace}') .should('have.value', '+7 (912) 345-78-9') .should('have.prop', 'selectionStart', '+7 (912) 345-'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-'.length) .type('0') .should('have.value', '+7 (912) 345-07-89') .should('have.prop', 'selectionStart', '+7 (912) 345-0'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-0'.length); }); it('+7 (912) 345-67-|89 => Delete + 0 => +7 (912) 345-67-0|9', () => { cy.get('@input') .focus() .type('{leftArrow}'.repeat('89'.length)) .should('have.prop', 'selectionStart', '+7 (912) 345-67-'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-67-'.length) .type('{del}') .should('have.value', '+7 (912) 345-67-9') .should('have.prop', 'selectionStart', '+7 (912) 345-67-'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-67-'.length) .type('0') .should('have.value', '+7 (912) 345-67-09') .should('have.prop', 'selectionStart', '+7 (912) 345-67-0'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-67-0'.length); }); it('+7 (912) 345-6|7-89 => Delete + 0 => +7 (912) 345-60-|89', () => { cy.get('@input') .focus() .type('{leftArrow}'.repeat('7-89'.length)) .should('have.prop', 'selectionStart', '+7 (912) 345-6'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-6'.length) .type('{del}') .should('have.value', '+7 (912) 345-68-9') .should('have.prop', 'selectionStart', '+7 (912) 345-6'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-6'.length) .type('0') .should('have.value', '+7 (912) 345-60-89') .should('have.prop', 'selectionStart', '+7 (912) 345-60-'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-60-'.length); }); it('+7 (912) 345-|67-89 => Delete + 0 => +7 (912) 345-0|7-89', () => { cy.get('@input') .focus() .type('{leftArrow}'.repeat('67-89'.length)) .should('have.prop', 'selectionStart', '+7 (912) 345-'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-'.length) .type('{del}') .should('have.value', '+7 (912) 345-78-9') .should('have.prop', 'selectionStart', '+7 (912) 345-'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-'.length) .type('0') .should('have.value', '+7 (912) 345-07-89') .should('have.prop', 'selectionStart', '+7 (912) 345-0'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-0'.length); }); }); describe('Press Backspace after fixed value => no value change => move caret to the left', () => { beforeEach(() => { cy.get('@input').type('9123456789'); }); it('+7 (912) 345-67-|89 => Backspace => +7 (912) 345-67|-89', () => { cy.get('@input') .focus() .type('{leftArrow}{leftArrow}') .should('have.prop', 'selectionStart', '+7 (912) 345-67-'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-67-'.length) .type('{backspace}') .should('have.value', '+7 (912) 345-67-89') .should('have.prop', 'selectionStart', '+7 (912) 345-67'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-67'.length); }); it('+7 (912) 345-|67-89 => Backspace => +7 (912) 345|-67-89', () => { cy.get('@input') .focus() .type('{leftArrow}'.repeat('67-89'.length)) .should('have.prop', 'selectionStart', '+7 (912) 345-'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-'.length) .type('{backspace}') .should('have.value', '+7 (912) 345-67-89') .should('have.prop', 'selectionStart', '+7 (912) 345'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345'.length); }); it('+7 (912) |345-67-89 => Backspace x2 => +7 (912|) 345-67-89', () => { cy.get('@input') .focus() .type('{leftArrow}'.repeat('345-67-89'.length)) .should('have.prop', 'selectionStart', '+7 (912) '.length) .should('have.prop', 'selectionEnd', '+7 (912) '.length) .type('{backspace}{backspace}') .should('have.value', '+7 (912) 345-67-89') .should('have.prop', 'selectionStart', '+7 (912'.length) .should('have.prop', 'selectionEnd', '+7 (912'.length); }); }); describe('Press Delete before fixed value => no value change => move caret to the right', () => { beforeEach(() => { cy.get('@input').type('9123456789'); }); it('+7 (912) 345-67|-89 => Delete => +7 (912) 345-67-|89', () => { cy.get('@input') .focus() .type('{leftArrow}'.repeat('-89'.length)) .should('have.prop', 'selectionStart', '+7 (912) 345-67'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-67'.length) .type('{del}') .should('have.value', '+7 (912) 345-67-89') .should('have.prop', 'selectionStart', '+7 (912) 345-67-'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-67-'.length); }); it('+7 (912) 345|-67-89 => Delete => +7 (912) 345-|67-89', () => { cy.get('@input') .focus() .type('{leftArrow}'.repeat('-67-89'.length)) .should('have.prop', 'selectionStart', '+7 (912) 345'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345'.length) .type('{del}') .should('have.value', '+7 (912) 345-67-89') .should('have.prop', 'selectionStart', '+7 (912) 345-'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-'.length); }); it('+7 (912|) 345-67-89 => Backspace x2 => +7 (912) |345-67-89', () => { cy.get('@input') .focus() .type('{leftArrow}'.repeat(') 345-67-89'.length)) .should('have.prop', 'selectionStart', '+7 (912'.length) .should('have.prop', 'selectionEnd', '+7 (912'.length) .type('{del}{del}') .should('have.value', '+7 (912) 345-67-89') .should('have.prop', 'selectionStart', '+7 (912) '.length) .should('have.prop', 'selectionEnd', '+7 (912) '.length); }); }); describe('Text selection', () => { // ab|cd|e – it means that in string "abcde" characters "cd" are selected beforeEach(() => { cy.get('@input').type('9123456789'); }); describe('Select range and press "Backspace"', () => { it( '+7 (912) 345-67-|89| => Backspace => +7 (912) 345-67|', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input').realPress([ 'Shift', 'ArrowLeft', 'ArrowLeft', 'Backspace', ]); cy.get('@input') .should('have.value', '+7 (912) 345-67') .should('have.prop', 'selectionStart', '+7 (912) 345-67'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-67'.length); }, ); it( '+7 (912) 345-6|7-89| => Backspace => +7 (912) 345-6|', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input').realPress([ 'Shift', ...repeatKey('ArrowLeft', '7-89'.length), 'Backspace', ]); cy.get('@input') .should('have.value', '+7 (912) 345-6') .should('have.prop', 'selectionStart', '+7 (912) 345-6'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-6'.length); }, ); it( '+7 (912) 345-6|7-8|9 => Backspace => +7 (912) 345-6|9', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input').realPress([ 'ArrowLeft', 'Shift', ...repeatKey('ArrowLeft', '7-8'.length), 'Backspace', ]); cy.get('@input') .should('have.value', '+7 (912) 345-69') .should('have.prop', 'selectionStart', '+7 (912) 345-6'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-6'.length); }, ); it( '+7 (912) 345-|67|-89 => Backspace => +7 (912) 345-|89', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input').realPress([ ...repeatKey('ArrowLeft', '-89'.length), 'Shift', ...repeatKey('ArrowLeft', '67'.length), 'Backspace', ]); cy.get('@input') .should('have.value', '+7 (912) 345-89') .should('have.prop', 'selectionStart', '+7 (912) 345-'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-'.length); }, ); it( '+7 (912) |345|-67-89 => Backspace => +7 (912) |678-9', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input').realPress([ ...repeatKey('ArrowLeft', '-67-89'.length), 'Shift', ...repeatKey('ArrowLeft', '345'.length), 'Backspace', ]); cy.get('@input') .should('have.value', '+7 (912) 678-9') .should('have.prop', 'selectionStart', '+7 (912) '.length) .should('have.prop', 'selectionEnd', '+7 (912) '.length); }, ); }); describe('Select range and press "Delete"', () => { it( '+7 (912) 345-67-|89| => Delete => +7 (912) 345-67|', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input').realPress(['Shift', 'ArrowLeft', 'ArrowLeft']); cy.get('@input') .type('{del}') .should('have.value', '+7 (912) 345-67') .should('have.prop', 'selectionStart', '+7 (912) 345-67'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-67'.length); }, ); it( '+7 (912) 345-6|7-89| => Delete => +7 (912) 345-6|', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input').realPress([ 'Shift', ...repeatKey('ArrowLeft', '7-89'.length), ]); cy.get('@input') .type('{del}') .should('have.value', '+7 (912) 345-6') .should('have.prop', 'selectionStart', '+7 (912) 345-6'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-6'.length); }, ); it( '+7 (912) 345-6|7-8|9 => Delete => +7 (912) 345-6|9', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input').realPress([ 'ArrowLeft', 'Shift', ...repeatKey('ArrowLeft', '7-8'.length), ]); cy.get('@input') .type('{del}') .should('have.value', '+7 (912) 345-69') .should('have.prop', 'selectionStart', '+7 (912) 345-6'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-6'.length); }, ); it( '+7 (912) 345-|67|-89 => Delete => +7 (912) 345-|89', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input').realPress([ ...repeatKey('ArrowLeft', '-89'.length), 'Shift', ...repeatKey('ArrowLeft', '67'.length), ]); cy.get('@input') .type('{del}') .should('have.value', '+7 (912) 345-89') .should('have.prop', 'selectionStart', '+7 (912) 345-'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-'.length); }, ); it( '+7 (912) |345|-67-89 => Delete => +7 (912) |678-9', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input').realPress([ ...repeatKey('ArrowLeft', '-67-89'.length), 'Shift', ...repeatKey('ArrowLeft', '345'.length), ]); cy.get('@input') .type('{del}') .should('have.value', '+7 (912) 678-9') .should('have.prop', 'selectionStart', '+7 (912) '.length) .should('have.prop', 'selectionEnd', '+7 (912) '.length); }, ); }); describe('Select range and press new digit', () => { it( '+7 (912) 345-67-|89| => Press 0 => +7 (912) 345-67-0|', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input').realPress(['Shift', 'ArrowLeft', 'ArrowLeft']); cy.get('@input') .type('0') .should('have.value', '+7 (912) 345-67-0') .should('have.prop', 'selectionStart', '+7 (912) 345-67-0'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-67-0'.length); }, ); it( '+7 (912) 345-6|7-89| => Press 0 => +7 (912) 345-60|', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input').realPress([ 'Shift', ...repeatKey('ArrowLeft', '7-89'.length), ]); cy.get('@input') .type('0') .should('have.value', '+7 (912) 345-60') .should('have.prop', 'selectionStart', '+7 (912) 345-60'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-60'.length); }, ); it( '+7 (912) 345-6|7-8|9 => Press 0 => +7 (912) 345-60-|9', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input').realPress([ 'ArrowLeft', 'Shift', ...repeatKey('ArrowLeft', '7-8'.length), ]); cy.get('@input') .type('0') .should('have.value', '+7 (912) 345-60-9') .should('have.prop', 'selectionStart', '+7 (912) 345-60-'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-60-'.length); }, ); it( '+7 (912) 345-|67|-89 => Press 0 => +7 (912) 345-0|8-9', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input').realPress([ ...repeatKey('ArrowLeft', '-89'.length), 'Shift', ...repeatKey('ArrowLeft', '67'.length), ]); cy.get('@input') .type('0') .should('have.value', '+7 (912) 345-08-9') .should('have.prop', 'selectionStart', '+7 (912) 345-0'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-0'.length); }, ); it( '+7 (912) |345|-67-89 => Press "0" => +7 (912) 0|67-89', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input').realPress([ ...repeatKey('ArrowLeft', '-67-89'.length), 'Shift', ...repeatKey('ArrowLeft', '345'.length), ]); cy.get('@input') .type('0') .should('have.value', '+7 (912) 067-89') .should('have.prop', 'selectionStart', '+7 (912) 0'.length) .should('have.prop', 'selectionEnd', '+7 (912) 0'.length); }, ); }); describe('Select fixed characters only', () => { it( 'and press Backspace => no changes => move caret to the left side', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input').realPress([ ...repeatKey('ArrowLeft', '345-67-89'.length), 'Shift', ...repeatKey('ArrowLeft', ') '.length), 'Backspace', ]); cy.get('@input') .should('have.value', '+7 (912) 345-67-89') .should('have.prop', 'selectionStart', '+7 (912'.length) .should('have.prop', 'selectionEnd', '+7 (912'.length); }, ); it( 'and press Delete => no changes => move caret to the right side', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input').realPress([ ...repeatKey('ArrowLeft', '345-67-89'.length), 'Shift', ...repeatKey('ArrowLeft', ') '.length), ]); cy.get('@input') .type('{del}') .should('have.value', '+7 (912) 345-67-89') .should('have.prop', 'selectionStart', '+7 (912) '.length) .should('have.prop', 'selectionEnd', '+7 (912) '.length); }, ); }); }); describe('Undo', () => { it('Select all + Delete => Ctrl + Z', () => { cy.get('@input') .type('9123456789') .type('{selectall}{del}') .should('have.value', '+7 ') .type('{ctrl+z}') .should('have.value', '+7 (912) 345-67-89'); }); it('+7 (912) 345-67|-89 => Backspace (x2) => Ctrl + Z (x2)', () => { cy.get('@input') .type('9123456789') .type('{leftArrow}'.repeat('-89'.length)) .type('{backspace}{backspace}') .should('have.value', '+7 (912) 345-89') .should('have.prop', 'selectionStart', '+7 (912) 345-'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-'.length) .type('{ctrl+z}') .should('have.value', '+7 (912) 345-68-9') .should('have.prop', 'selectionStart', '+7 (912) 345-6'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-6'.length) .type('{ctrl+z}') .should('have.value', '+7 (912) 345-67-89') .should('have.prop', 'selectionStart', '+7 (912) 345-67'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-67'.length); }); it( '+7 (912) |345-67|-89 => Delete => Cmd + Z', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('9123456789') .realPress([ ...repeatKey('ArrowLeft', '-89'.length), 'Shift', ...repeatKey('ArrowLeft', '345-67'.length), ]); cy.get('@input') .type('{del}') .should('have.value', '+7 (912) 89') .should('have.prop', 'selectionStart', '+7 (912) '.length) .should('have.prop', 'selectionEnd', '+7 (912) '.length) .type('{cmd+z}') .should('have.value', '+7 (912) 345-67-89') .should('have.prop', 'selectionStart', '+7 (912) '.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-67'.length); }, ); }); describe('Redo', () => { it('Select all + Delete => Cmd + Z => Cmd + Shift + Z', () => { cy.get('@input') .type('9123456789') .type('{selectall}{del}') .should('have.value', '+7 ') .type('{cmd+z}') .should('have.value', '+7 (912) 345-67-89') .type('{cmd+shift+z}') .should('have.value', '+7 '); }); it('+7 (912) 345-67|-89 => Backspace (x2) => Ctrl + Z (x2) => Ctrl + Y (x2)', () => { cy.get('@input') .type('9123456789') .type('{leftArrow}'.repeat('-89'.length)) .type('{backspace}{backspace}') .type('{ctrl+z}{ctrl+z}') .type('{ctrl+y}{ctrl+y}') .should('have.value', '+7 (912) 345-89') .should('have.prop', 'selectionStart', '+7 (912) 345-'.length) .should('have.prop', 'selectionEnd', '+7 (912) 345-'.length); }); it( '+7 (912) |345-67|-89 => Delete => Cmd + Z => Cmd + Shift + Z', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('9123456789') .realPress([ ...repeatKey('ArrowLeft', '-89'.length), 'Shift', ...repeatKey('ArrowLeft', '345-67'.length), ]); cy.get('@input') .type('{del}') .type('{cmd+z}') .type('{cmd+shift+z}') .should('have.value', '+7 (912) 89') .should('have.prop', 'selectionStart', '+7 (912) '.length) .should('have.prop', 'selectionEnd', '+7 (912) '.length); }, ); }); describe('Non-removable country prefix', () => { it('cannot be removed via selectAll + Backspace', () => { cy.get('@input') .type('9123456789') .type('{selectall}{backspace}') .should('have.value', '+7 ') .should('have.prop', 'selectionStart', '+7 '.length) .should('have.prop', 'selectionEnd', '+7 '.length); }); it('cannot be removed via selectAll + Delete', () => { cy.get('@input') .type('9123456789') .type('{selectall}{del}') .should('have.value', '+7 ') .should('have.prop', 'selectionStart', '+7 '.length) .should('have.prop', 'selectionEnd', '+7 '.length); }); it('cannot be removed via Backspace', () => { cy.get('@input') .type('9123456789') .type('{backspace}'.repeat('+7 (912) 345-89'.length)) .should('have.value', '+7 ') .should('have.prop', 'selectionStart', '+7 '.length) .should('have.prop', 'selectionEnd', '+7 '.length); }); it('cannot be removed via Delete', () => { cy.get('@input') .type('9123456789') .type('{moveToStart}') .type('{del}'.repeat('+7 (912) 345-89'.length)) .should('have.value', '+7 ') .should('have.prop', 'selectionStart', '+7 '.length) .should('have.prop', 'selectionEnd', '+7 '.length); }); it('appears on focus if input is empty', () => { cy.get('@input') .blur() .should('have.value', '') .focus() .should('have.value', '+7 ') .should('have.prop', 'selectionStart', '+7 '.length) .should('have.prop', 'selectionEnd', '+7 '.length); }); it('disappears on blur if there are no more digits except it', () => { cy.get('@input') .focus() .should('have.value', '+7 ') .blur() .should('have.value', ''); }); describe('with caret guard', () => { it('forbids to put caret before country prefix', () => { cy.get('@input') .should('have.value', '+7 ') .should('have.prop', 'selectionStart', '+7 '.length) .should('have.prop', 'selectionEnd', '+7 '.length) .type('{moveToStart}') .should('have.value', '+7 ') .should('have.prop', 'selectionStart', '+7 '.length) .should('have.prop', 'selectionEnd', '+7 '.length) .type('{leftArrow}'.repeat(5)) .should('have.value', '+7 ') .should('have.prop', 'selectionStart', '+7 '.length) .should('have.prop', 'selectionEnd', '+7 '.length); }); it('can be selected via selectAll', () => { cy.get('@input') .type('9123456789') .type('{selectall}') .should('have.value', '+7 (912) 345-67-89') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', '+7 (912) 345-67-89'.length); }); }); }); describe('New typed character is equal to the previous (already existing) fixed character', () => { it('+7 | => Type 7 => +7 (7', () => { cy.get('@input') .type('7') .should('have.value', '+7 (7') .should('have.prop', 'selectionStart', '+7 (7'.length) .should('have.prop', 'selectionEnd', '+7 (7'.length); }); it('+7 (7| => Type 7 => +7 (77', () => { cy.get('@input') .type('77') .should('have.value', '+7 (77') .should('have.prop', 'selectionStart', '+7 (77'.length) .should('have.prop', 'selectionEnd', '+7 (77'.length); }); }); it('pressing double space twice does not delete character', () => { cy.get('@input') .type('1234567890') .should('have.value', '+7 (123) 456-78-90') .should('have.prop', 'selectionStart', '+7 (123) 456-78-90'.length) .should('have.prop', 'selectionEnd', '+7 (123) 456-78-90'.length) .type(' ') .type(' ') .should('have.value', '+7 (123) 456-78-90') .should('have.prop', 'selectionStart', '+7 (123) 456-78-90'.length) .should('have.prop', 'selectionEnd', '+7 (123) 456-78-90'.length); }); }); ================================================ FILE: projects/demo-integrations/src/tests/recipes/placeholder/date.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Placeholder | Date', () => { beforeEach(() => { cy.visit(DemoPath.Placeholder); cy.get('#date input') .should('be.visible') .first() .should('have.value', '') .focus() .should('have.value', 'dd/mm/yyyy') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0) .as('input'); cy.get('#date tui-input').as('inputWrapper'); }); describe('basic typing (1 character per keydown)', () => { const tests = [ // [Typed value, Masked value, caretIndex] ['1', '1d/mm/yyyy', 1], ['16', '16/mm/yyyy', '16'.length], ['160', '16/0m/yyyy', '16/0'.length], ['1605', '16/05/yyyy', '16/05'.length], ['16052', '16/05/2yyy', '16/05/2'.length], ['160520', '16/05/20yy', '16/05/20'.length], ['1605202', '16/05/202y', '16/05/202'.length], ['16052023', '16/05/2023', '16/05/2023'.length], ] as const; tests.forEach(([typed, masked, caretIndex]) => { it(`Type ${typed} => ${masked}`, () => { cy.get('@input') .type(typed) .should('have.value', masked) .should('have.prop', 'selectionStart', caretIndex) .should('have.prop', 'selectionEnd', caretIndex); }); }); }); it('Type 999 => 09/09/9yyy', () => { cy.get('@input') .type('999') .should('have.value', '09/09/9yyy') .should('have.prop', 'selectionStart', '09/09/9'.length) .should('have.prop', 'selectionEnd', '09/09/9'.length); }); it('Type 39 => 03/09/yyyy', () => { cy.get('@input') .type('39') .should('have.value', '03/09/yyyy') .should('have.prop', 'selectionStart', '03/09'.length) .should('have.prop', 'selectionEnd', '03/09'.length); }); it('Type 31/13 => 31/1m/yyyy', () => { cy.get('@input') .type('3113') .should('have.value', '31/1m/yyyy') .should('have.prop', 'selectionStart', '31/1'.length) .should('have.prop', 'selectionEnd', '31/1'.length); }); it('Cannot move caret outside actual value', () => { cy.get('@input') .type('311') .type('{rightArrow}') .should('have.prop', 'selectionStart', '31/1'.length) .should('have.prop', 'selectionEnd', '31/1'.length) .type('{selectAll}') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', '31/1'.length); }); it('Removes placeholder on blur', () => { cy.get('@input') .type('311') .should('have.value', '31/1m/yyyy') .should('have.prop', 'selectionStart', '31/1'.length) .should('have.prop', 'selectionEnd', '31/1'.length) .blur() .should('have.value', '31/1'); }); it('Removes placeholder from Angular control', () => { cy.get('@input') .type('311') .should('have.value', '31/1m/yyyy') .blur() .should('have.value', '31/1'); cy.get('@inputWrapper').should('have.ngControlValue', '31/1'); }); }); ================================================ FILE: projects/demo-integrations/src/tests/recipes/placeholder/us-phone.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Placeholder | US phone', () => { beforeEach(() => { cy.visit(DemoPath.Placeholder); cy.get('#phone input') .should('be.visible') .first() .should('have.value', '') .focus() .should('have.value', '+1 (   ) ___-____') .should('have.prop', 'selectionStart', '+1'.length) .should('have.prop', 'selectionEnd', '+1'.length) .as('input'); }); describe('basic typing (1 character per keydown)', () => { const tests = [ // [Typed value, Masked value, valueWithoutPlaceholder] ['2', '+1 (2  ) ___-____', '+1 (2'], ['21', '+1 (21 ) ___-____', '+1 (21'], ['212', '+1 (212) ___-____', '+1 (212'], ['2125', '+1 (212) 5__-____', '+1 (212) 5'], ['21255', '+1 (212) 55_-____', '+1 (212) 55'], ['212555', '+1 (212) 555-____', '+1 (212) 555'], ['2125552', '+1 (212) 555-2___', '+1 (212) 555-2'], ['21255523', '+1 (212) 555-23__', '+1 (212) 555-23'], ['212555236', '+1 (212) 555-236_', '+1 (212) 555-236'], ['2125552368', '+1 (212) 555-2368', '+1 (212) 555-2368'], ] as const; tests.forEach(([typed, masked, valueWithoutPlaceholder]) => { it(`Type ${typed} => ${masked}`, () => { cy.get('@input') .type(typed) .should('have.value', masked) .should('have.prop', 'selectionStart', valueWithoutPlaceholder.length) .should('have.prop', 'selectionEnd', valueWithoutPlaceholder.length) .blur() .should('have.value', valueWithoutPlaceholder); }); }); }); it('Can type 1 after country code +1', () => { cy.get('@input') .type('1') .should('have.value', '+1 (1  ) ___-____') .should('have.prop', 'selectionStart', '+1 (1'.length) .should('have.prop', 'selectionEnd', '+1 (1'.length); }); it('cannot erase country code +1', () => { cy.get('@input') .type('{backspace}'.repeat(10)) .should('have.value', '+1 (   ) ___-____') .type('{selectAll}{backspace}') .should('have.value', '+1 (   ) ___-____') .type('{selectAll}{del}') .should('have.value', '+1 (   ) ___-____') .should('have.prop', 'selectionStart', '+1'.length) .should('have.prop', 'selectionEnd', '+1'.length); }); it('cannot move caret outside actual value', () => { cy.get('@input') .type('{rightArrow}') .should('have.prop', 'selectionStart', '+1'.length) .should('have.prop', 'selectionEnd', '+1'.length) .type('{selectAll}') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', '+1'.length); }); it('Value contains only country code and placeholder => Blur => Value is empty', () => { cy.get('@input') .focus() .should('have.value', '+1 (   ) ___-____') .blur() .should('have.value', ''); }); describe('caret navigation on attempt to erase fixed character', () => { beforeEach(() => { cy.get('@input') .type('2125552') .should('have.value', '+1 (212) 555-2___') .should('have.prop', 'selectionStart', '+1 (212) 555-2'.length) .should('have.prop', 'selectionEnd', '+1 (212) 555-2'.length); }); it('+1 (212) 555-|2___ => Backspace => +1 (212) 555|-2___', () => { cy.get('@input') .type('{leftArrow}{backspace}') .should('have.value', '+1 (212) 555-2___') .should('have.prop', 'selectionStart', '+1 (212) 555'.length) .should('have.prop', 'selectionEnd', '+1 (212) 555'.length); }); it('+1 (212) 555|-2___ => Delete => +1 (212) 555-|2___', () => { cy.get('@input') .type('{leftArrow}'.repeat(2)) .should('have.prop', 'selectionStart', '+1 (212) 555'.length) .should('have.prop', 'selectionEnd', '+1 (212) 555'.length) .type('{del}') .should('have.value', '+1 (212) 555-2___') .should('have.prop', 'selectionStart', '+1 (212) 555-'.length) .should('have.prop', 'selectionEnd', '+1 (212) 555-'.length); }); it('+1 (212) |555-2___ => Backspace x2 => +1 (212|) 555-2___', () => { cy.get('@input') .type('{leftArrow}'.repeat('555-2'.length)) .type('{backspace}') .should('have.value', '+1 (212) 555-2___') .should('have.prop', 'selectionStart', '+1 (212)'.length) .should('have.prop', 'selectionEnd', '+1 (212)'.length) .type('{backspace}') .should('have.value', '+1 (212) 555-2___') .should('have.prop', 'selectionStart', '+1 (212'.length) .should('have.prop', 'selectionEnd', '+1 (212'.length); }); it('+1 (212|) 555-2___ => Delete => +1 (212) |555-2___', () => { cy.get('@input') .type('{leftArrow}'.repeat(') 555-2'.length)) .should('have.prop', 'selectionStart', '+1 (212'.length) .should('have.prop', 'selectionEnd', '+1 (212'.length) .type('{del}') .should('have.value', '+1 (212) 555-2___') .should('have.prop', 'selectionStart', '+1 (212) '.length) .should('have.prop', 'selectionEnd', '+1 (212) '.length); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/recipes/placeholder/сvc-code.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Placeholder | CVC code', () => { beforeEach(() => { cy.visit(DemoPath.Placeholder); cy.get('#cvc input') .should('be.visible') .first() .focus() .should('have.value', 'xxx') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0) .as('input'); }); it('Type 1 => 1|xx', () => { cy.get('@input') .type('1') .should('have.value', '1xx') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('Type 12 => 12|x', () => { cy.get('@input') .type('12') .should('have.value', '12x') .should('have.prop', 'selectionStart', 2) .should('have.prop', 'selectionEnd', 2); }); it('Type 123 => 123|', () => { cy.get('@input') .type('123') .should('have.value', '123') .should('have.prop', 'selectionStart', 3) .should('have.prop', 'selectionEnd', 3); }); it('12|3 => Backspace => 1|3x', () => { cy.get('@input') .type('123') .type('{leftArrow}{backspace}') .should('have.value', '13x') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('1|3x => Type 0 => 10|3', () => { cy.get('@input') .type('13') .type('{leftArrow}0') .should('have.value', '103') .should('have.prop', 'selectionStart', 2) .should('have.prop', 'selectionEnd', 2); }); it('1xx => select all => backspace => xxx', () => { cy.get('@input') .type('1') .type('{selectAll}{backspace}') .should('have.value', 'xxx') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('1xx => select all => delete => xxx', () => { cy.get('@input') .type('1') .type('{selectAll}{del}') .should('have.value', 'xxx') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('1x|x => 1|xx', () => { cy.get('@input') .type('1') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .type('{rightArrow}') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('12|x => select all => |12|x', () => { cy.get('@input') .type('12') .should('have.prop', 'selectionStart', 2) .should('have.prop', 'selectionEnd', 2) .type('{selectAll}') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 2); }); }); ================================================ FILE: projects/demo-integrations/src/tests/recipes/plugins/reject.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Plugins | Reject', () => { const original = 'none'; const rejected = 'reject-0'; beforeEach(() => { cy.visit(DemoPath.KitPlugins); cy.get('#reject input') .should('be.visible') .first() .should('have.value', '') .should('have.css', 'animation-name', original) .focus() .as('input'); }); it('Allows digits', () => { cy.get('@input') .type('1') .should('have.value', '1') .should('have.css', 'animation-name', original); }); it('Rejects letters', () => { cy.get('@input') .type('1a') .should('have.value', '1') .should('have.css', 'animation-name', rejected); }); }); ================================================ FILE: projects/demo-integrations/src/tests/recipes/postfix/percentage.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Postfix | Dynamic Pattern Mask Expression', () => { beforeEach(() => { cy.visit(DemoPath.Postfix); cy.get('#by-pattern-mask-expression input') .should('be.visible') .first() .should('have.value', '') .focus() .as('input'); }); it('Empty input => Type 1 => 1|%', () => { cy.get('@input') .type('1') .should('have.value', '1%') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('Empty input => Type 10 => 10|%', () => { cy.get('@input') .type('10') .should('have.value', '10%') .should('have.prop', 'selectionStart', 2) .should('have.prop', 'selectionEnd', 2); }); it('10|% => Backspace => 1|%', () => { cy.get('@input') .type('10') .type('{backspace}') .should('have.value', '1%') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('1|% => Backspace => Empty input', () => { cy.get('@input') .type('1') .type('{backspace}') .should('have.value', '') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('|53% => Delete => |3%', () => { cy.get('@input') .type('53') .type('{moveToStart}{del}') .should('have.value', '3%') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('|3% => Delete => Empty input', () => { cy.get('@input') .type('3') .type('{moveToStart}{del}') .should('have.value', '') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('cannot erase the % (by backspace)', () => { cy.get('@input') .type('42') .type('{moveToEnd}') .should('have.value', '42%') .should('have.prop', 'selectionStart', '42%'.length) .should('have.prop', 'selectionEnd', '42%'.length) .type('{backspace}') .should('have.value', '42%') .should('have.prop', 'selectionStart', '42'.length) .should('have.prop', 'selectionEnd', '42'.length); }); it('cannot erase the % (by delete)', () => { cy.get('@input') .type('42') .type('{del}') .should('have.value', '42%') .should('have.prop', 'selectionStart', '42%'.length) .should('have.prop', 'selectionEnd', '42%'.length); }); }); ================================================ FILE: projects/demo-integrations/src/tests/recipes/postfix/postprocessor.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Postfix | Postprocessor (maskitoPostfixPostprocessorGenerator)', () => { beforeEach(() => { cy.visit(DemoPath.Postfix); cy.get('#by-postprocessor input') .should('be.visible') .first() .should('have.value', '') .focus() .as('input'); }); it('Type 100 => $100|.00', () => { cy.get('@input') .should('have.value', '$.00') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .type('100') .should('have.value', '$100.00') .should('have.prop', 'selectionStart', '$100'.length) .should('have.prop', 'selectionEnd', '$100'.length); }); it('$10|0.00 => Backspace => Type 5 => $15|0.00', () => { cy.get('@input') .should('have.value', '$.00') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .type('100') .type('{leftArrow}{backspace}') .type('5') .should('have.value', '$150.00') .should('have.prop', 'selectionStart', '$15'.length) .should('have.prop', 'selectionEnd', '$15'.length); }); describe('Attempts to delete the prefix', () => { beforeEach(() => { cy.get('@input') .should('have.value', '$.00') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .type('1') .should('have.value', '$1.00') .should('have.prop', 'selectionStart', 2) .should('have.prop', 'selectionEnd', 2); }); it('$|1.00 => Backspace => $|1.00', () => { cy.get('@input') .type('{leftArrow}') .type('{backspace}') .should('have.value', '$1.00') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('$1.00 => Select All => Delete => $|.00', () => { cy.get('@input') .type('{selectAll}{del}') .should('have.value', '$.00') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); }); describe('Caret guard works', () => { beforeEach(() => { cy.get('@input') .should('have.value', '$.00') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .type('42') .should('have.value', '$42.00'); }); it('forbids to put caret before the prefix', () => { cy.get('@input') .type('{moveToStart}') .should('have.value', '$42.00') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .type('{leftArrow}'.repeat(5)) .should('have.value', '$42.00') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('forbids to put caret after the postfix', () => { cy.get('@input') .type('{moveToEnd}') .should('have.value', '$42.00') .should('have.prop', 'selectionStart', '$42'.length) .should('have.prop', 'selectionEnd', '$42'.length) .type('{rightArrow}'.repeat(5)) .should('have.value', '$42.00') .should('have.prop', 'selectionStart', '$42'.length) .should('have.prop', 'selectionEnd', '$42'.length); }); it('forbids to select prefix/postfix via select all', () => { cy.get('@input') .type('{selectAll}') .should('have.value', '$42.00') .should('have.prop', 'selectionStart', '$'.length) .should('have.prop', 'selectionEnd', '$42'.length); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/recipes/prefix/dynamic-pattern-mask-expression.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Prefix | Dynamic Pattern Mask Expression', () => { beforeEach(() => { cy.visit(DemoPath.Prefix); cy.get('#by-pattern-mask-expression input') .should('be.visible') .first() .should('have.value', '') .focus() .as('input'); }); it('Empty input => $ => $', () => { cy.get('@input') .type('$') .should('have.value', '$') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('Empty input => Type 42 => $42|', () => { cy.get('@input') .type('42') .should('have.value', '$42') .should('have.prop', 'selectionStart', '$42'.length) .should('have.prop', 'selectionEnd', '$42'.length); }); it('$42| => Backspace => $4|', () => { cy.get('@input') .type('42') .type('{backspace}') .should('have.value', '$4') .should('have.prop', 'selectionStart', '$4'.length) .should('have.prop', 'selectionEnd', '$4'.length); }); it('$4| => Backspace => Empty input', () => { cy.get('@input') .type('4') .type('{backspace}') .should('have.value', '') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); describe('cannot erase prefix if there are digits after it', () => { it('via Backspace', () => { cy.get('@input') .type('42') .type('{moveToStart}{rightArrow}') .type('{backspace}') .should('have.value', '$42') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('via Delete', () => { cy.get('@input') .type('42') .type('{moveToStart}') .type('{del}') .should('have.value', '$42') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); }); describe('rejects extra prefix (dollar sign)', () => { it('Empty input => $$$$$ => $', () => { cy.get('@input') .type('$$$$$') .should('have.value', '$') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('Empty input => $42$ => $42|', () => { cy.get('@input') .type('$42$') .should('have.value', '$42') .should('have.prop', 'selectionStart', '$42'.length) .should('have.prop', 'selectionEnd', '$42'.length); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/recipes/prefix/postprocessor.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Prefix | Postprocessor (maskitoPrefixPostprocessorGenerator)', () => { beforeEach(() => { cy.visit(DemoPath.Prefix); cy.get('#by-postprocessor input') .should('be.visible') .first() .should('have.value', '') .as('input'); }); it('Empty input => Focus => $|', () => { cy.get('@input') .focus() .should('have.value', '$') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('$| => Blur => Empty input', () => { cy.get('@input') .focus() .should('have.value', '$') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .blur() .should('have.value', ''); }); it('Empty input => Focus + Type 42 => $42|', () => { cy.get('@input') .focus() .type('42') .should('have.value', '$42') .should('have.prop', 'selectionStart', '$42'.length) .should('have.prop', 'selectionEnd', '$42'.length); }); it('$42| => Backspace => $4|', () => { cy.get('@input') .focus() .type('42') .type('{backspace}') .should('have.value', '$4') .should('have.prop', 'selectionStart', '$4'.length) .should('have.prop', 'selectionEnd', '$4'.length); }); it('$4| => Backspace => $|', () => { cy.get('@input') .focus() .type('4') .type('{backspace}') .should('have.value', '$') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); describe('cannot erase prefix', () => { it('via Backspace (+ do not move caret behind prefix)', () => { cy.get('@input') .focus() .type('42') .type('{moveToStart}{rightArrow}') .type('{backspace}') .should('have.value', '$42') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1) .type('{moveToEnd}') .type('{backspace}'.repeat(5)) .should('have.value', '$') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('via Delete', () => { cy.get('@input') .focus() .type('42') .type('{moveToStart}') .type('{del}') .should('have.value', '$42') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('via selectAll + delete', () => { cy.get('@input') .focus() .type('42') .type('{selectAll}{del}') .should('have.value', '$') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); }); describe('rejects extra prefix (dollar sign)', () => { it('Empty input => $$$$$ => $', () => { cy.get('@input') .focus() .type('$$$$$') .should('have.value', '$') .should('have.prop', 'selectionStart', 1) .should('have.prop', 'selectionEnd', 1); }); it('Empty input => $42$ => $42|', () => { cy.get('@input') .focus() .type('$42$') .should('have.value', '$42') .should('have.prop', 'selectionStart', '$42'.length) .should('have.prop', 'selectionEnd', '$42'.length); }); }); }); ================================================ FILE: projects/demo-integrations/src/tests/recipes/textarea/textarea-latin-letters-digits.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Textarea (mask latin letters + digits)', () => { beforeEach(() => { cy.visit(DemoPath.Textarea); cy.get('#latin textarea[autocomplete="street-address"]') .should('be.visible') .should('have.value', '') .focus() .as('textArea'); }); describe('Line break (Enter)', () => { it('can insert line break at the beginning', () => { cy.get('@textArea') .type('{enter}') .type('Taiga UI') .should('have.value', '\nTaiga UI'); }); it('can insert line break at the end', () => { cy.get('@textArea') .type('Taiga') .type('{enter}') .type('UI') .should('have.value', 'Taiga\nUI'); }); it('can insert line break at the middle', () => { cy.get('@textArea') .type('TaigaUI') .type('{leftArrow}{leftArrow}') .type('{enter}') .should('have.value', 'Taiga\nUI'); }); it('can insert many line breaks', () => { cy.get('@textArea') .type('Taiga') .type('{enter}{enter}{enter}') .type('UI') .should('have.value', 'Taiga\n\n\nUI'); }); it('`deleteSoftLineBackward` works', () => { cy.get('@textArea') .type('Taiga') .type('{enter}') .type('UI and Maskito') .trigger('beforeinput', {inputType: 'deleteSoftLineBackward'}) .trigger('input', {inputType: 'deleteSoftLineBackward'}) .should('have.value', 'Taiga\n'); }); it('`deleteSoftLineForward` works', () => { cy.get('@textArea') .type('Taiga') .type('{enter}') .type('UI and Maskito') .type('{moveToStart}') .trigger('beforeinput', {inputType: 'deleteSoftLineForward'}) .trigger('input', {inputType: 'deleteSoftLineForward'}) .should('have.value', 'UI and Maskito'); }); }); it('accepts spaces', () => { const TYPED_VALUE = '1 2 3 4 5'; cy.get('@textArea').type(TYPED_VALUE).should('have.value', TYPED_VALUE); }); it('rejects cyrillic symbols', () => { cy.get('@textArea') .type('123абвгдеёЖзийклмноGgпрстуфхцчшщъыьэюя456') .should('have.value', '123Gg456'); }); describe('Type `deleteWordBackward` of `InputEvent`', () => { const tests = [ {initialValue: '1 34 678', newValue: '1 34 '}, {initialValue: '1 34 ', newValue: '1 '}, {initialValue: '1 34', newValue: '1 '}, {initialValue: '1 ', newValue: ''}, {initialValue: '1', newValue: ''}, ] as const; tests.forEach(({initialValue, newValue}) => { it(`"${initialValue}|" => Ctrl + Backspace => "${newValue}|"`, () => { cy.get('@textArea') .type(initialValue) .type('{ctrl+backspace}') .should('have.value', newValue) .should('have.prop', 'selectionStart', newValue.length) .should('have.prop', 'selectionEnd', newValue.length); }); }); }); it('Type `deleteWordBackward` of `InputEvent`', () => { cy.get('@textArea') .type('1 34 678') .type('{moveToStart}') .type('{ctrl+del}') .should('have.value', ' 34 678') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0) .type('{ctrl+del}') .should('have.value', ' 678') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0) .type('{ctrl+del}') .should('have.value', '') .should('have.prop', 'selectionStart', 0) .should('have.prop', 'selectionEnd', 0); }); it('allows to paste dot with following space after Backspace', () => { cy.get('@textArea') .type('123') .type('#') // rejected by mask .type('{backspace}') .paste('. ') .should('have.value', '12. '); }); }); ================================================ FILE: projects/demo-integrations/src/tests/ssr/ssr.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('Server side rendering', () => { beforeEach(() => { // Just a workaround for correct work of global run-time error handler // See projects/demo-integrations/src/support/e2e.ts cy.visit(DemoPath.WhatIsMaskito); }); const baseUrl = Cypress.config('baseUrl') ?? '/'; it('should serve statics and favicon.ico', () => { cy.request(`${baseUrl}/favicon.ico`).its('status').should('equal', 200); }); it('should successfully render lazy url', () => { cy.request(`${baseUrl}/${DemoPath.Time}`) .its('body') .should('include.match', /\s+Time/); }); }); ================================================ FILE: projects/demo-integrations/src/tests/utils.ts ================================================ import {type realPress} from 'cypress-real-events/commands/realPress'; export function range(from: number, to: number): number[] { return Array.from({length: to - from + 1}).map((_, i) => from + i); } export function withCaretLabel(value: string, caretIndex: number): string { return `${value.slice(0, caretIndex)}|${value.slice(caretIndex)}`; } export function repeatKey[0]>( key: T, times: number, ): readonly T[] { return Array.from({length: times}).map(() => key); } ================================================ FILE: projects/demo-integrations/src/tests/vue/vue.cy.ts ================================================ import {DemoPath} from '@demo/constants'; describe('@maskito/vue | Basic', () => { beforeEach(() => { cy.visit(DemoPath.Vue); cy.get('#example input').should('be.visible').clear().as('input'); }); it('rejects invalid characters', () => { cy.get('@input').type('1a2b3c').should('have.value', '123'); }); it('accepts valid digits', () => { cy.get('@input').type('123456789').should('have.value', '123_456_789'); }); }); ================================================ FILE: projects/demo-integrations/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "typeRoots": ["../../node_modules/@types", "../../node_modules/cypress/types"], "types": ["cypress", "node"] }, "include": [ "**/*.ts", "**/*.tsx", "**/*.d.ts" ], "exclude": [] } ================================================ FILE: projects/demo-integrations/vite.config.ts ================================================ import {nxViteTsPaths} from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; import react from '@vitejs/plugin-react'; import {defineConfig} from 'vite'; export default defineConfig({ plugins: [ nxViteTsPaths(), // https://nx.dev/recipes/vite/configure-vite#typescript-paths react(), // https://nx.dev/recipes/vite/configure-vite#framework-plugins ], }); ================================================ FILE: projects/kit/README.md ================================================ # @maskito/kit [![npm version](https://img.shields.io/npm/v/@maskito/kit.svg)](https://npmjs.com/package/@maskito/kit) [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@maskito/kit)](https://bundlephobia.com/result?p=@maskito/kit)

Maskito logo

DocumentationSubmit an IssueContact Us

> The optional framework-agnostic Maskito's package.
It contains ready-to-use masks with configurable parameters. ## How to install ```bash npm i @maskito/{core,kit} ``` ================================================ FILE: projects/kit/jest.config.ts ================================================ export default { displayName: 'kit', preset: '../../jest.preset.js', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], coverageDirectory: '../../coverage/kit', }; ================================================ FILE: projects/kit/package.json ================================================ { "name": "@maskito/kit", "version": "5.2.2", "description": "The optional framework-agnostic Maskito's package with ready-to-use masks", "keywords": [ "input", "mask", "inputmask", "input-mask", "text-mask", "format", "input-format", "input-formatting", "javascript", "typescript" ], "homepage": "https://maskito.dev", "bugs": "https://github.com/taiga-family/maskito/issues", "repository": { "type": "git", "url": "https://github.com/taiga-family/maskito.git" }, "license": "Apache-2.0", "author": { "email": "nikita.s.barsukov@gmail.com", "name": "Nikita Barsukov", "url": "https://github.com/nsbarsukov" }, "contributors": [ { "email": "alexander@inkin.ru", "name": "Alex Inkin" }, { "email": "vladimir.potekh@gmail.com", "name": "Vladimir Potekhin" }, { "email": "nikita.s.barsukov@gmail.com", "name": "Nikita Barsukov" }, { "email": "nextzeddicus@gmail.com", "name": "Georgiy Lunin" } ], "peerDependencies": { "@maskito/core": "^5.2.2" } } ================================================ FILE: projects/kit/project.json ================================================ { "$schema": "../../node_modules/nx/schemas/project-schema.json", "name": "kit", "implicitDependencies": ["!demo"], "projectType": "library", "sourceRoot": "projects/kit/src", "tags": [], "targets": { "build": { "dependsOn": [ { "dependencies": true, "params": "forward", "target": "build" } ], "executor": "@nx/rollup:rollup", "options": { "assets": [ { "glob": "README.md", "input": "{projectRoot}", "output": "." } ], "compiler": "tsc", "external": "all", "format": ["esm", "cjs"], "main": "{projectRoot}/src/index.ts", "outputPath": "dist/{projectName}", "project": "{projectRoot}/package.json", "tsConfig": "tsconfig.build.json", "useLegacyTypescriptPlugin": false }, "outputs": ["{options.outputPath}"] }, "publish": { "dependsOn": [ { "params": "ignore", "target": "build" } ], "executor": "nx:run-commands", "options": { "command": "npm publish ./dist/{projectName} --ignore-scripts" } }, "test": { "executor": "@nx/jest:jest", "options": { "jestConfig": "{projectRoot}/jest.config.ts" }, "outputs": ["{workspaceRoot}/coverage/{projectName}"] } } } ================================================ FILE: projects/kit/src/index.ts ================================================ export { maskitoDateOptionsGenerator, type MaskitoDateParams, maskitoParseDate, maskitoStringifyDate, } from './lib/masks/date'; export {maskitoDateRangeOptionsGenerator} from './lib/masks/date-range'; export { maskitoDateTimeOptionsGenerator, type MaskitoDateTimeParams, maskitoParseDateTime, maskitoStringifyDateTime, } from './lib/masks/date-time'; export { maskitoNumberOptionsGenerator, type MaskitoNumberParams, maskitoParseNumber, maskitoStringifyNumber, } from './lib/masks/number'; export { maskitoParseTime, maskitoStringifyTime, maskitoTimeOptionsGenerator, type MaskitoTimeParams, } from './lib/masks/time'; export { maskitoAddOnFocusPlugin, maskitoCaretGuard, maskitoEventHandler, maskitoRejectEvent, maskitoRemoveOnBlurPlugin, maskitoSelectionChangeHandler, } from './lib/plugins'; export { maskitoPostfixPostprocessorGenerator, maskitoPrefixPostprocessorGenerator, maskitoWithPlaceholder, } from './lib/processors'; export type { MaskitoDateMode, MaskitoDateSegments, MaskitoTimeMode, MaskitoTimeSegments, } from './lib/types'; ================================================ FILE: projects/kit/src/lib/constants/date-segment-max-values.ts ================================================ import type {MaskitoDateSegments} from '../types'; export const DATE_SEGMENTS_MAX_VALUES: MaskitoDateSegments = { day: 31, month: 12, year: 9999, }; ================================================ FILE: projects/kit/src/lib/constants/default-decimal-pseudo-separators.ts ================================================ export const DEFAULT_DECIMAL_PSEUDO_SEPARATORS = ['.', ',', 'б', 'ю']; ================================================ FILE: projects/kit/src/lib/constants/default-min-max-dates.ts ================================================ export const DEFAULT_MIN_DATE = new Date('0001-01-01T00:00'); export const DEFAULT_MAX_DATE = new Date('9999-12-31T23:59:59.999'); ================================================ FILE: projects/kit/src/lib/constants/default-pseudo-minuses.ts ================================================ import { CHAR_EM_DASH, CHAR_EN_DASH, CHAR_HYPHEN, CHAR_JP_HYPHEN, CHAR_MINUS, } from './unicode-characters'; export const DEFAULT_PSEUDO_MINUSES = [ CHAR_HYPHEN, CHAR_EN_DASH, CHAR_EM_DASH, CHAR_JP_HYPHEN, CHAR_MINUS, ]; ================================================ FILE: projects/kit/src/lib/constants/default-time-segment-bounds.ts ================================================ import type {MaskitoTimeSegments} from '../types'; export const DEFAULT_TIME_SEGMENT_MAX_VALUES: MaskitoTimeSegments = { hours: 23, minutes: 59, seconds: 59, milliseconds: 999, }; export const DEFAULT_TIME_SEGMENT_MIN_VALUES: MaskitoTimeSegments = { hours: 0, minutes: 0, seconds: 0, milliseconds: 0, }; ================================================ FILE: projects/kit/src/lib/constants/index.ts ================================================ export * from './date-segment-max-values'; export * from './default-decimal-pseudo-separators'; export * from './default-min-max-dates'; export * from './default-pseudo-minuses'; export * from './default-time-segment-bounds'; export * from './meridiem'; export * from './time-fixed-characters'; export * from './time-segment-value-lengths'; export * from './unicode-characters'; ================================================ FILE: projects/kit/src/lib/constants/meridiem.ts ================================================ import {CHAR_NO_BREAK_SPACE} from './unicode-characters'; export const ANY_MERIDIEM_CHARACTER_RE = new RegExp(`[${CHAR_NO_BREAK_SPACE}APM]+$`, 'g'); export const ALL_MERIDIEM_CHARACTERS_RE = new RegExp(`${CHAR_NO_BREAK_SPACE}[AP]M$`, 'g'); ================================================ FILE: projects/kit/src/lib/constants/time-fixed-characters.ts ================================================ export const TIME_FIXED_CHARACTERS = [':', '.']; ================================================ FILE: projects/kit/src/lib/constants/time-segment-value-lengths.ts ================================================ import type {MaskitoTimeSegments} from '../types'; export const TIME_SEGMENT_VALUE_LENGTHS: MaskitoTimeSegments = { hours: 2, minutes: 2, seconds: 2, milliseconds: 3, }; ================================================ FILE: projects/kit/src/lib/constants/unicode-characters.ts ================================================ /** * {@link https://unicode-table.com/en/00A0/ Non-breaking space}. */ export const CHAR_NO_BREAK_SPACE = '\u00A0'; /** * {@link https://symbl.cc/en/200B/ Zero width space}. */ export const CHAR_ZERO_WIDTH_SPACE = '\u200B'; /** * {@link https://unicode-table.com/en/2013/ EN dash} * is used to indicate a range of numbers or a span of time. * @example 2006–2022 */ export const CHAR_EN_DASH = '\u2013'; /** * {@link https://unicode-table.com/en/2014/ EM dash} * is used to mark a break in a sentence. * @example Taiga UI — powerful set of open source components for Angular * ___ * Don't confuse with {@link CHAR_EN_DASH} or {@link CHAR_HYPHEN}! */ export const CHAR_EM_DASH = '\u2014'; /** * {@link https://unicode-table.com/en/002D/ Hyphen (minus sign)} * is used to combine words. * @example well-behaved * ___ * Don't confuse with {@link CHAR_EN_DASH} or {@link CHAR_EM_DASH}! */ export const CHAR_HYPHEN = '\u002D'; /** * {@link https://unicode-table.com/en/2212/ Minus} * is used as math operator symbol or before negative digits. * --- * Can be used as `−`. Don't confuse with {@link CHAR_HYPHEN} */ export const CHAR_MINUS = '\u2212'; /** * {@link https://symbl.cc/en/30FC/ Katakana-Hiragana Prolonged Sound Mark} * is used as prolonged sounds in Japanese. */ export const CHAR_JP_HYPHEN = '\u30FC'; /** * {@link https://symbl.cc/en/003A/ Colon} * is a punctuation mark that connects parts of a text logically. * --- * is also used as separator in time. */ export const CHAR_COLON = '\u003A'; /** * {@link https://symbl.cc/en/FF1A/ Full-width colon} * is a full-width punctuation mark used to separate parts of a text commonly in Japanese. */ export const CHAR_JP_COLON = '\uFF1A'; ================================================ FILE: projects/kit/src/lib/masks/date/date-mask.ts ================================================ import {MASKITO_DEFAULT_OPTIONS, type MaskitoOptions} from '@maskito/core'; import { createDateSegmentsZeroPaddingPostprocessor, createFullWidthToHalfWidthPreprocessor, createMinMaxDatePostprocessor, createValidDatePreprocessor, createZeroPlaceholdersPreprocessor, normalizeDatePreprocessor, } from '../../processors'; import type {MaskitoDateParams} from './date-params'; export function maskitoDateOptionsGenerator({ mode, separator = '.', max, min, }: MaskitoDateParams): Required { const dateModeTemplate = mode.split('/').join(separator); return { ...MASKITO_DEFAULT_OPTIONS, mask: Array.from(dateModeTemplate).map((char) => separator.includes(char) ? char : /\d/, ), overwriteMode: 'replace', preprocessors: [ createFullWidthToHalfWidthPreprocessor(), createZeroPlaceholdersPreprocessor(), normalizeDatePreprocessor({ dateModeTemplate, dateSegmentsSeparator: separator, }), createValidDatePreprocessor({ dateModeTemplate, dateSegmentsSeparator: separator, }), ], postprocessors: [ createDateSegmentsZeroPaddingPostprocessor({ dateModeTemplate, dateSegmentSeparator: separator, splitFn: (value) => ({dateStrings: [value]}), uniteFn: ([dateString = '']) => dateString, }), createMinMaxDatePostprocessor({ min, max, dateModeTemplate, dateSegmentSeparator: separator, }), ], }; } ================================================ FILE: projects/kit/src/lib/masks/date/date-params.ts ================================================ import type {MaskitoDateMode} from '../../types'; export interface MaskitoDateParams { mode: MaskitoDateMode; separator?: string; max?: Date; min?: Date; } ================================================ FILE: projects/kit/src/lib/masks/date/index.ts ================================================ export * from './date-mask'; export * from './date-params'; export * from './utils'; ================================================ FILE: projects/kit/src/lib/masks/date/tests/date-mask.spec.ts ================================================ /** * If any of these tests fail, * it can mean that browser autofill or composition are not working properly * for Date mask */ import {beforeEach, describe, expect, it} from '@jest/globals'; import { MASKITO_DEFAULT_OPTIONS, type MaskitoOptions, maskitoTransform, } from '@maskito/core'; import {maskitoDateOptionsGenerator} from '@maskito/kit'; describe('Date (maskitoTransform)', () => { describe('[mode]="yyyy/mm/dd"', () => { let options: MaskitoOptions = MASKITO_DEFAULT_OPTIONS; beforeEach(() => { options = maskitoDateOptionsGenerator({ mode: 'yyyy/mm/dd', separator: '/', }); }); describe('pads digits with zero if date segment exceeds its max possible value', () => { describe('pads digit > 1 with zero for months', () => { [0, 1].forEach((digit) => { it(`1234/${digit} => 1234/${digit}`, () => { expect(maskitoTransform(`1234${digit}`, options)).toBe( `1234/${digit}`, ); expect(maskitoTransform(`1234/${digit}`, options)).toBe( `1234/${digit}`, ); }); }); [2, 3, 4, 5, 6, 7, 8, 9].forEach((digit) => { it(`1234/${digit} => 1234/0${digit}`, () => { expect(maskitoTransform(`1234${digit}`, options)).toBe( `1234/0${digit}`, ); expect(maskitoTransform(`1234/${digit}`, options)).toBe( `1234/0${digit}`, ); }); }); }); describe('pads digit > 3 with zero for days', () => { [0, 1, 2, 3].forEach((digit) => { it(`1234/12/${digit} => 1234/12/${digit}`, () => { expect(maskitoTransform(`123412${digit}`, options)).toBe( `1234/12/${digit}`, ); expect(maskitoTransform(`1234/12/${digit}`, options)).toBe( `1234/12/${digit}`, ); }); }); [4, 5, 6, 7, 8, 9].forEach((digit) => { it(`1234/12/${digit} => 1234/12/0${digit}`, () => { expect(maskitoTransform(`123412${digit}`, options)).toBe( `1234/12/0${digit}`, ); expect(maskitoTransform(`1234/12/${digit}`, options)).toBe( `1234/12/0${digit}`, ); }); }); }); }); it('accepts full width characters', () => { expect(maskitoTransform('12345', options)).toBe('1234/05'); expect(maskitoTransform('12341226', options)).toBe('1234/12/26'); }); }); }); ================================================ FILE: projects/kit/src/lib/masks/date/utils/index.ts ================================================ export {maskitoParseDate} from './parse-date'; export {maskitoStringifyDate} from './stringify-date'; ================================================ FILE: projects/kit/src/lib/masks/date/utils/parse-date.ts ================================================ import {DEFAULT_MAX_DATE, DEFAULT_MIN_DATE} from '../../../constants'; import {clamp, parseDateString, segmentsToDate} from '../../../utils'; import type {MaskitoDateParams} from '../date-params'; export function maskitoParseDate( value: string, {mode, min = DEFAULT_MIN_DATE, max = DEFAULT_MAX_DATE}: MaskitoDateParams, ): Date | null { const digitsPattern = mode.replaceAll(/[^dmy]/g, ''); const digits = value.replaceAll(/\D+/g, ''); if (digits.length !== digitsPattern.length) { return null; } const dateSegments = parseDateString(value, mode); const parsedDate = segmentsToDate(dateSegments); return mode.includes('y') ? clamp(parsedDate, min, max) : parsedDate; } ================================================ FILE: projects/kit/src/lib/masks/date/utils/stringify-date.ts ================================================ import {DEFAULT_MAX_DATE, DEFAULT_MIN_DATE} from '../../../constants'; import {clamp, toDateString} from '../../../utils'; import type {MaskitoDateParams} from '../date-params'; import {toDateSegments} from './to-date-segments'; export function maskitoStringifyDate( date: Date, { mode, separator = '.', min = DEFAULT_MIN_DATE, max = DEFAULT_MAX_DATE, }: MaskitoDateParams, ): string { const validatedDate = clamp(date, min, max); const {year, ...segments} = toDateSegments(validatedDate); return toDateString( {...segments, year: year.padStart(mode.match(/y/g)?.length ?? 0, '0')}, {dateMode: mode.replaceAll('/', separator)}, ); } ================================================ FILE: projects/kit/src/lib/masks/date/utils/tests/parse-date.spec.ts ================================================ import type {MaskitoDateParams} from '../../date-params'; import {maskitoParseDate} from '../parse-date'; describe('maskitoParseDate', () => { describe('mode = mm/dd/yyyy, separator = default', () => { let params: MaskitoDateParams; beforeEach(() => { params = { mode: 'mm/dd/yyyy', min: new Date('2004-02-05T00:00:00.000'), max: new Date('2024-01-01T00:00:00.000'), }; }); it('should correctly parse 04/29/2012', () => { const parsedDate = maskitoParseDate('04/29/2012', params); expect(parsedDate?.getTime()).toBe(Date.parse('2012-04-29T00:00:00.000')); }); it('should return min date on 02/04/2004', () => { const parsedDate = maskitoParseDate('02/04/2004', params); expect(parsedDate?.getTime()).toBe(params.min?.getTime()); }); it('should return max date on 01/02/2024', () => { const parsedDate = maskitoParseDate('01/02/2024', params); expect(parsedDate?.getTime()).toBe(params.max?.getTime()); }); }); describe('incomplete, mode = mm/dd/yyyy, separator = default', () => { let params: MaskitoDateParams; beforeEach(() => { params = { mode: 'mm/dd/yyyy', min: new Date('2004-02-05T00:00:00.000'), max: new Date('2024-01-03T00:00:00.000'), }; }); it('should return null, 04/mm/yyyy', () => { const parsedDate = maskitoParseDate('04', params); expect(parsedDate).toBeNull(); }); it('should return null 02/04/yyyy', () => { const parsedDate = maskitoParseDate('02/04', params); expect(parsedDate).toBeNull(); }); it('should return null 01/02/2yyy', () => { const parsedDate = maskitoParseDate('01/02/2', params); expect(parsedDate).toBeNull(); }); it('should return null 01/02/20yy', () => { const parsedDate = maskitoParseDate('01/02/20', params); expect(parsedDate).toBeNull(); }); it('should return null 01/02/202y', () => { const parsedDate = maskitoParseDate('01/02/202', params); expect(parsedDate).toBeNull(); }); it('should return date on 01/02/2024', () => { const parsedDate = maskitoParseDate('01/02/2024', params); expect(parsedDate?.getTime()).toBe(Date.parse('2024-01-02T00:00:00.000')); }); }); describe('mode = mm/dd/yyyy, separator = -', () => { let params: MaskitoDateParams; beforeEach(() => { params = { mode: 'mm/dd/yyyy', separator: '-', min: new Date('2004-02-28T00:00:00.000'), max: new Date('2030-01-01T00:00:00.000'), }; }); it('should correctly parse 12-31-2020', () => { const parsedDate = maskitoParseDate('12-31-2020', params); expect(parsedDate?.getTime()).toBe(Date.parse('2020-12-31T00:00:00.000')); }); it('should return min date on 02-27-2004', () => { const parsedDate = maskitoParseDate('02-27-2004', params); expect(parsedDate?.getTime()).toBe(params.min?.getTime()); }); it('should return max date on 01/02/2030', () => { const parsedDate = maskitoParseDate('01/02/2030', params); expect(parsedDate?.getTime()).toBe(params.max?.getTime()); }); }); describe('mode = mm/yy, separator = :', () => { let params: MaskitoDateParams; beforeEach(() => { params = { mode: 'mm/yy', separator: ':', min: new Date('2004-02-28T00:00:00.000'), max: new Date('2030-01-01T00:00:00.000'), }; }); it('should correctly parse 02:12', () => { const parsedDate = maskitoParseDate('02:12', params); expect(parsedDate?.getTime()).toBe(Date.parse('2012-02-01T00:00:00.000')); }); it('should return min date on 01:03', () => { const parsedDate = maskitoParseDate('01:03', params); expect(parsedDate?.getTime()).toBe(params.min?.getTime()); }); it('should return max date on 02:31', () => { const parsedDate = maskitoParseDate('02:30', params); expect(parsedDate?.getTime()).toBe(params.max?.getTime()); }); }); describe('mode = yyyy/mm, separator = .', () => { let params: MaskitoDateParams; beforeEach(() => { params = { mode: 'yyyy/mm', separator: '.', min: new Date('2004-02-28T00:00:00.000'), max: new Date('2030-01-01T00:00:00.000'), }; }); it('should correctly parse 2012.08', () => { const parsedDate = maskitoParseDate('2012.08', params); expect(parsedDate?.getTime()).toBe(Date.parse('2012-08-01T00:00:00.000')); }); it('should return min date on 1991.01', () => { const parsedDate = maskitoParseDate('1991.01', params); expect(parsedDate?.getTime()).toBe(params.min?.getTime()); }); it('should return max date on 2033.12', () => { const parsedDate = maskitoParseDate('2033.12', params); expect(parsedDate?.getTime()).toBe(params.max?.getTime()); }); }); describe('mode without year segment', () => { it('should parse mm/dd and preserve month/day values', () => { const parsedDate = maskitoParseDate('12/25', { mode: 'mm/dd', separator: '/', }); expect(parsedDate?.getTime()).toBe(Date.parse('0000-12-25T00:00:00.000')); }); it('should parse dd/mm and preserve day/month values', () => { const parsedDate = maskitoParseDate('25/12', { mode: 'dd/mm', separator: '/', }); expect(parsedDate?.getTime()).toBe(Date.parse('0000-12-25T00:00:00.000')); }); }); describe('invalid date strings', () => { const params: MaskitoDateParams = { mode: 'mm/dd/yyyy', min: new Date('2000-01-01T00:00:00.000'), max: new Date('2030-01-01T00:00:00.000'), }; it.each([ 'this-is-not-a-date', '', ' ', '12//2020', '/31/2020', '12/31/', '12/31/20', '12/31/abcd', '1a/31/2020', ])('should return null for "%s"', (value) => { expect(maskitoParseDate(value, params)).toBeNull(); }); it('should return null for a date with incomplete year and multi-char separator', () => { expect( maskitoParseDate('16-01-20', { mode: 'dd/mm/yyyy', separator: '-', }), ).toBeNull(); expect( maskitoParseDate('16--01--20', { mode: 'dd/mm/yyyy', separator: '--', }), ).toBeNull(); }); it('should parse a date with complete year and multi-char separator', () => { expect( maskitoParseDate('16-01-2026', { mode: 'dd/mm/yyyy', separator: '-', })?.getTime(), ).toBe(Date.parse('2026-01-16T00:00:00.000')); expect( maskitoParseDate('16--01--2026', { mode: 'dd/mm/yyyy', separator: '--', })?.getTime(), ).toBe(Date.parse('2026-01-16T00:00:00.000')); }); }); }); ================================================ FILE: projects/kit/src/lib/masks/date/utils/tests/stringify-date.spec.ts ================================================ import type {MaskitoDateMode} from '../../../../types'; import type {MaskitoDateParams} from '../../date-params'; import {maskitoStringifyDate} from '../stringify-date'; describe('maskitoStringifyDate', () => { const testCases = new Map< MaskitoDateMode, Array & {date: Date; text: string}> >([ [ 'dd/mm', [ {date: new Date('2004-02-01'), text: '01.02'}, {date: new Date('2012-12-30'), separator: '-', text: '30-12'}, { date: new Date('2012-12-30'), max: new Date('2012-12-29'), text: '29.12', }, { date: new Date('2012-12-12'), min: new Date('2012-12-13'), text: '13.12', }, ], ], [ 'dd/mm/yyyy', [ {date: new Date('2012-03-25'), text: '25.03.2012'}, { date: new Date('2024-03-25'), max: new Date('2024-01-31'), separator: '/', text: '31/01/2024', }, { date: new Date('2024-01-31'), min: new Date('2024-03-25'), separator: ':', text: '25:03:2024', }, ], ], [ 'mm/dd', [ {date: new Date('2004-02-01'), text: '02.01'}, {date: new Date('2012-12-30'), separator: '-', text: '12-30'}, { date: new Date('2012-12-30'), max: new Date('2012-12-29'), text: '12.29', }, { date: new Date('2012-12-12'), min: new Date('2012-12-13'), text: '12.13', }, ], ], [ 'mm/dd/yyyy', [ {date: new Date('2000-10-01'), text: '10.01.2000'}, { date: new Date('2024-03-25'), max: new Date('2024-01-31'), separator: '/', text: '01/31/2024', }, { date: new Date('2024-01-31'), min: new Date('2024-03-25'), separator: '-', text: '03-25-2024', }, ], ], [ 'mm/yy', [ {date: new Date('2000-10-01'), text: '10.00'}, { date: new Date('2012-10-20'), max: new Date('2012-10-19'), separator: '/', text: '10/12', }, { date: new Date('2024-01-31'), min: new Date('2024-03-25'), text: '03.24', }, ], ], [ 'mm/yyyy', [ {date: new Date('2000-10-01'), text: '10.2000'}, { date: new Date('2012-10-20'), max: new Date('2012-10-19'), separator: '/', text: '10/2012', }, { date: new Date('2024-01-31'), min: new Date('2024-03-25'), text: '03.2024', }, ], ], [ 'yyyy', [ {date: new Date('2000-10-01'), text: '2000'}, { date: new Date('2012-10-20'), max: new Date('2011-10-19'), text: '2011', }, { date: new Date('2024-01-31'), min: new Date('2050-03-25'), text: '2050', }, ], ], [ 'yyyy/mm', [ {date: new Date('2000-10-01'), text: '2000.10'}, { date: new Date('2012-10-20'), max: new Date('2012-09-19'), separator: '-', text: '2012-09', }, { date: new Date('2024-01-31'), min: new Date('2024-03-25'), text: '2024.03', }, ], ], [ 'yyyy/mm/dd', [ {date: new Date('2000-10-01'), text: '2000.10.01'}, { date: new Date('2024-03-25'), max: new Date('2024-01-31'), separator: '/', text: '2024/01/31', }, { date: new Date('2024-01-31'), min: new Date('2024-03-25'), text: '2024.03.25', }, ], ], ]); testCases.forEach((cases, mode) => { describe(`mode ${mode}`, () => { cases.forEach(({date, separator, text, min, max}) => { it(`${date.toString()} => '${text}'`, () => { expect(maskitoStringifyDate(date, {mode, separator, min, max})).toBe( text, ); }); }); }); }); describe('year contains leading zeroes', () => { const date = new Date('0042-02-13T00:00:00.000'); it('dd/mm/yyyy', () => { expect(maskitoStringifyDate(date, {mode: 'dd/mm/yyyy'})).toBe('13.02.0042'); }); it('yyyy/mm/dd', () => { expect(maskitoStringifyDate(date, {mode: 'yyyy/mm/dd'})).toBe('0042.02.13'); }); it('mm/yy', () => { expect(maskitoStringifyDate(date, {mode: 'mm/yy'})).toBe('02.42'); }); }); }); ================================================ FILE: projects/kit/src/lib/masks/date/utils/to-date-segments.ts ================================================ import type {MaskitoDateSegments} from '../../../types'; const formatter = Intl.DateTimeFormat('en-US', { month: '2-digit', day: '2-digit', year: 'numeric', }); export function toDateSegments(date: Date): MaskitoDateSegments { return formatter .formatToParts(date) .reduce( (acc, part) => ({...acc, [part.type]: part.value}), {} as MaskitoDateSegments, ); } ================================================ FILE: projects/kit/src/lib/masks/date-range/constants.ts ================================================ import { CHAR_EM_DASH, CHAR_EN_DASH, CHAR_HYPHEN, CHAR_JP_HYPHEN, CHAR_MINUS, } from '../../constants'; export const POSSIBLE_DATE_RANGE_SEPARATOR = [ CHAR_HYPHEN, CHAR_EN_DASH, CHAR_EM_DASH, CHAR_MINUS, CHAR_JP_HYPHEN, ]; export const MIN_DAY = 1; export const MONTHS_IN_YEAR = 12; export const MonthNumber = { January: 0, February: 1, March: 2, April: 3, May: 4, June: 5, July: 6, August: 7, September: 8, October: 9, November: 10, December: 11, } as const; ================================================ FILE: projects/kit/src/lib/masks/date-range/date-range-mask.ts ================================================ import {MASKITO_DEFAULT_OPTIONS, type MaskitoOptions} from '@maskito/core'; import {CHAR_EN_DASH, CHAR_NO_BREAK_SPACE} from '../../constants'; import { createDateSegmentsZeroPaddingPostprocessor, createFirstDateEndSeparatorPreprocessor, createFullWidthToHalfWidthPreprocessor, createMinMaxDatePostprocessor, createValidDatePreprocessor, createZeroPlaceholdersPreprocessor, normalizeDatePreprocessor, } from '../../processors'; import type {MaskitoDateMode, MaskitoDateSegments} from '../../types'; import {parseDateRangeString} from '../../utils'; import {POSSIBLE_DATE_RANGE_SEPARATOR} from './constants'; import {createMinMaxRangeLengthPostprocessor} from './processors/min-max-range-length-postprocessor'; import {createSwapDatesPostprocessor} from './processors/swap-dates-postprocessor'; export function maskitoDateRangeOptionsGenerator({ mode, min, max, minLength, maxLength, dateSeparator = '.', rangeSeparator = `${CHAR_NO_BREAK_SPACE}${CHAR_EN_DASH}${CHAR_NO_BREAK_SPACE}`, }: { mode: MaskitoDateMode; min?: Date; max?: Date; minLength?: Partial>; maxLength?: Partial>; dateSeparator?: string; rangeSeparator?: string; }): Required { const dateModeTemplate = mode.split('/').join(dateSeparator); const dateMask = Array.from(dateModeTemplate).map((char) => dateSeparator.includes(char) ? char : /\d/, ); return { ...MASKITO_DEFAULT_OPTIONS, mask: [...dateMask, ...Array.from(rangeSeparator), ...dateMask], overwriteMode: 'replace', preprocessors: [ createFullWidthToHalfWidthPreprocessor(), createFirstDateEndSeparatorPreprocessor({ dateModeTemplate, dateSegmentSeparator: dateSeparator, firstDateEndSeparator: rangeSeparator, pseudoFirstDateEndSeparators: POSSIBLE_DATE_RANGE_SEPARATOR, }), createZeroPlaceholdersPreprocessor(), normalizeDatePreprocessor({ dateModeTemplate, rangeSeparator, dateSegmentsSeparator: dateSeparator, }), createValidDatePreprocessor({ dateModeTemplate, rangeSeparator, dateSegmentsSeparator: dateSeparator, }), ], postprocessors: [ createDateSegmentsZeroPaddingPostprocessor({ dateModeTemplate, dateSegmentSeparator: dateSeparator, splitFn: (value) => ({ dateStrings: parseDateRangeString( value, dateModeTemplate, rangeSeparator, ), }), uniteFn: (validatedDateStrings, initialValue) => validatedDateStrings.reduce( (acc, dateString, dateIndex) => `${acc}${dateString}${ !dateIndex && initialValue.includes(rangeSeparator) ? rangeSeparator : '' }`, '', ), }), createMinMaxDatePostprocessor({ min, max, dateModeTemplate, rangeSeparator, dateSegmentSeparator: dateSeparator, }), createMinMaxRangeLengthPostprocessor({ dateModeTemplate, minLength, maxLength, max, rangeSeparator, }), createSwapDatesPostprocessor({ dateModeTemplate, rangeSeparator, }), ], }; } ================================================ FILE: projects/kit/src/lib/masks/date-range/index.ts ================================================ export * from './date-range-mask'; ================================================ FILE: projects/kit/src/lib/masks/date-range/processors/min-max-range-length-postprocessor.ts ================================================ import type {MaskitoPostprocessor} from '@maskito/core'; import {DEFAULT_MAX_DATE} from '../../../constants'; import type {MaskitoDateSegments} from '../../../types'; import { appendDate, clamp, dateToSegments, identity, isDateStringComplete, isEmpty, parseDateRangeString, parseDateString, segmentsToDate, toDateString, } from '../../../utils'; export function createMinMaxRangeLengthPostprocessor({ dateModeTemplate, rangeSeparator, minLength, maxLength, max = DEFAULT_MAX_DATE, }: { dateModeTemplate: string; rangeSeparator: string; max?: Date; minLength?: Partial>; maxLength?: Partial>; }): MaskitoPostprocessor { if (isEmpty(minLength) && isEmpty(maxLength)) { return identity; } return ({value, selection}) => { const dateStrings = parseDateRangeString(value, dateModeTemplate, rangeSeparator); if ( dateStrings.length !== 2 || dateStrings.some((date) => !isDateStringComplete(date, dateModeTemplate)) ) { return {value, selection}; } const [fromDate, toDate] = dateStrings.map((dateString) => segmentsToDate(parseDateString(dateString, dateModeTemplate)), ); if (!fromDate || !toDate) { return {value, selection}; } const minDistantToDate = appendDate(fromDate, minLength); const maxDistantToDate = isEmpty(maxLength) ? max : appendDate(fromDate, maxLength); const minLengthClampedToDate = clamp(toDate, minDistantToDate, max); const minMaxLengthClampedToDate = minLengthClampedToDate > maxDistantToDate ? maxDistantToDate : minLengthClampedToDate; return { selection, value: dateStrings[0] + rangeSeparator + toDateString(dateToSegments(minMaxLengthClampedToDate), { dateMode: dateModeTemplate, }), }; }; } ================================================ FILE: projects/kit/src/lib/masks/date-range/processors/swap-dates-postprocessor.ts ================================================ import type {MaskitoPostprocessor} from '@maskito/core'; import { isDateStringComplete, parseDateRangeString, parseDateString, segmentsToDate, } from '../../../utils'; export function createSwapDatesPostprocessor({ dateModeTemplate, rangeSeparator, }: { dateModeTemplate: string; rangeSeparator: string; }): MaskitoPostprocessor { return ({value, selection}) => { const dateStrings = parseDateRangeString(value, dateModeTemplate, rangeSeparator); const isDateRangeComplete = dateStrings.length === 2 && dateStrings.every((date) => isDateStringComplete(date, dateModeTemplate)); const [from, to] = selection; const caretAtTheEnd = from >= value.length; const allValueSelected = from === 0 && to >= value.length; // dropping text inside with a pointer if (!(caretAtTheEnd || allValueSelected) || !isDateRangeComplete) { return {value, selection}; } const [fromDate, toDate] = dateStrings.map((dateString) => segmentsToDate(parseDateString(dateString, dateModeTemplate)), ); return { selection, value: fromDate && toDate && fromDate > toDate ? dateStrings.reverse().join(rangeSeparator) : value, }; }; } ================================================ FILE: projects/kit/src/lib/masks/date-range/tests/date-segments-zero-padding.spec.ts ================================================ import {beforeEach, describe, expect, it} from '@jest/globals'; import { MASKITO_DEFAULT_OPTIONS, type MaskitoOptions, maskitoTransform, } from '@maskito/core'; import {maskitoDateRangeOptionsGenerator} from '@maskito/kit'; describe('DateRange (maskitoTransform) | Date segments zero padding', () => { describe('[mode]="yyyy/mm/dd"', () => { let options: MaskitoOptions = MASKITO_DEFAULT_OPTIONS; beforeEach(() => { options = maskitoDateRangeOptionsGenerator({ mode: 'yyyy/mm/dd', dateSeparator: '/', rangeSeparator: '-', }); }); describe('pads digits with zero if date segment exceeds its max possible value', () => { describe('pads digit > 1 with zero for months', () => { [0, 1].forEach((digit) => { it(`1234/${digit} => 1234/${digit}`, () => { expect(maskitoTransform(`1234${digit}`, options)).toBe( `1234/${digit}`, ); expect( maskitoTransform(`1234/01/01-1234/${digit}`, options), ).toBe(`1234/01/01-1234/${digit}`); }); }); [2, 3, 4, 5, 6, 7, 8, 9].forEach((digit) => { it(`1234/${digit} => 1234/0${digit}`, () => { expect(maskitoTransform(`1234${digit}`, options)).toBe( `1234/0${digit}`, ); expect( maskitoTransform(`1234/01/01-1234/${digit}`, options), ).toBe(`1234/01/01-1234/0${digit}`); }); }); }); describe('pads digit > 3 with zero for days', () => { [0, 1, 2, 3].forEach((digit) => { it(`1234/12/${digit} => 1234/12/${digit}`, () => { expect(maskitoTransform(`123412${digit}`, options)).toBe( `1234/12/${digit}`, ); expect( maskitoTransform(`1234/01/01-1234/12/${digit}`, options), ).toBe(`1234/01/01-1234/12/${digit}`); }); }); [4, 5, 6, 7, 8, 9].forEach((digit) => { it(`1234/12/${digit} => 1234/12/0${digit}`, () => { expect(maskitoTransform(`123412${digit}`, options)).toBe( `1234/12/0${digit}`, ); expect( maskitoTransform(`1234/01/01-1234/12/${digit}`, options), ).toBe(`1234/01/01-1234/12/0${digit}`); }); }); }); }); }); }); ================================================ FILE: projects/kit/src/lib/masks/date-range/tests/pseudo-range-separators.spec.ts ================================================ import {beforeEach, describe, expect, it} from '@jest/globals'; import { MASKITO_DEFAULT_OPTIONS, type MaskitoOptions, maskitoTransform, } from '@maskito/core'; import {maskitoDateRangeOptionsGenerator} from '@maskito/kit'; import {CHAR_EM_DASH, CHAR_EN_DASH, CHAR_HYPHEN, CHAR_MINUS} from '../../../constants'; describe('DateRange (maskitoTransform) | Pseudo range separators', () => { let options: MaskitoOptions = MASKITO_DEFAULT_OPTIONS; beforeEach(() => { options = maskitoDateRangeOptionsGenerator({ mode: 'dd/mm/yyyy', dateSeparator: '.', rangeSeparator: CHAR_EN_DASH, }); }); it('works with already valid range separator', () => { expect(maskitoTransform(`01012000${CHAR_EN_DASH}10102000`, options)).toBe( `01.01.2000${CHAR_EN_DASH}10.10.2000`, ); expect(maskitoTransform(`01012000 ${CHAR_EN_DASH} 10102000`, options)).toBe( `01.01.2000${CHAR_EN_DASH}10.10.2000`, ); }); it('replaces hyphen with valid range separator', () => { expect(maskitoTransform(`01012000${CHAR_HYPHEN}10102000`, options)).toBe( `01.01.2000${CHAR_EN_DASH}10.10.2000`, ); }); it('replaces em-dash with valid range separator', () => { expect(maskitoTransform(`01012000${CHAR_EM_DASH}10102000`, options)).toBe( `01.01.2000${CHAR_EN_DASH}10.10.2000`, ); }); it('replaces minus with valid range separator', () => { expect(maskitoTransform(`01012000${CHAR_MINUS}10102000`, options)).toBe( `01.01.2000${CHAR_EN_DASH}10.10.2000`, ); }); }); ================================================ FILE: projects/kit/src/lib/masks/date-time/constants/date-time-separator.ts ================================================ export const DATE_TIME_SEPARATOR = ', '; export const POSSIBLE_DATE_TIME_SEPARATOR = [',', ' ']; ================================================ FILE: projects/kit/src/lib/masks/date-time/constants/index.ts ================================================ export * from './date-time-separator'; ================================================ FILE: projects/kit/src/lib/masks/date-time/date-time-mask.ts ================================================ import {MASKITO_DEFAULT_OPTIONS, type MaskitoOptions} from '@maskito/core'; import { DEFAULT_TIME_SEGMENT_MAX_VALUES, DEFAULT_TIME_SEGMENT_MIN_VALUES, } from '../../constants'; import { createMeridiemSteppingPlugin, createTimeSegmentsSteppingPlugin, } from '../../plugins'; import { createColonConvertPreprocessor, createDateSegmentsZeroPaddingPostprocessor, createFirstDateEndSeparatorPreprocessor, createFullWidthToHalfWidthPreprocessor, createInvalidTimeSegmentInsertionPreprocessor, createMeridiemPostprocessor, createMeridiemPreprocessor, createZeroPlaceholdersPreprocessor, normalizeDatePreprocessor, } from '../../processors'; import type {MaskitoTimeSegments} from '../../types'; import {createTimeMaskExpression} from '../../utils/time'; import {DATE_TIME_SEPARATOR} from './constants'; import type {MaskitoDateTimeParams} from './date-time-params'; import {createMinMaxDateTimePostprocessor} from './postprocessors'; import {createValidDateTimePreprocessor} from './preprocessors'; import {splitDateTimeString} from './utils'; export function maskitoDateTimeOptionsGenerator({ dateMode, timeMode, dateSeparator = '.', min, max, dateTimeSeparator = DATE_TIME_SEPARATOR, timeStep = 0, }: MaskitoDateTimeParams): Required { const hasMeridiem = timeMode.includes('AA'); const dateModeTemplate = dateMode.split('/').join(dateSeparator); const timeSegmentMaxValues: MaskitoTimeSegments = { ...DEFAULT_TIME_SEGMENT_MAX_VALUES, ...(hasMeridiem ? {hours: 12} : {}), }; const timeSegmentMinValues: MaskitoTimeSegments = { ...DEFAULT_TIME_SEGMENT_MIN_VALUES, ...(hasMeridiem ? {hours: 1} : {}), }; const fullMode = `${dateModeTemplate}${dateTimeSeparator}${timeMode}`; return { ...MASKITO_DEFAULT_OPTIONS, mask: [ ...Array.from(dateModeTemplate).map((char) => dateSeparator.includes(char) ? char : /\d/, ), ...dateTimeSeparator.split(''), ...createTimeMaskExpression(timeMode), ], overwriteMode: 'replace', preprocessors: [ createFullWidthToHalfWidthPreprocessor(), createColonConvertPreprocessor(), createFirstDateEndSeparatorPreprocessor({ dateModeTemplate, dateSegmentSeparator: dateSeparator, firstDateEndSeparator: dateTimeSeparator, pseudoFirstDateEndSeparators: dateTimeSeparator.split(''), }), createZeroPlaceholdersPreprocessor(), createMeridiemPreprocessor(timeMode), normalizeDatePreprocessor({ dateModeTemplate, dateSegmentsSeparator: dateSeparator, dateTimeSeparator, }), createInvalidTimeSegmentInsertionPreprocessor({ timeMode, timeSegmentMinValues, timeSegmentMaxValues, parseValue: (x) => { const [dateString, timeString] = splitDateTimeString( x, dateModeTemplate, ); return {timeString, restValue: `${dateString}${dateTimeSeparator}`}; }, }), createValidDateTimePreprocessor({ dateModeTemplate, dateSegmentsSeparator: dateSeparator, dateTimeSeparator, timeMode, timeSegmentMaxValues, }), ], postprocessors: [ createMeridiemPostprocessor(timeMode), createDateSegmentsZeroPaddingPostprocessor({ dateModeTemplate, dateSegmentSeparator: dateSeparator, splitFn: (value) => { const [dateString, timeString] = splitDateTimeString( value, dateModeTemplate, ); return {dateStrings: [dateString], restPart: timeString}; }, uniteFn: ([validatedDateString], initialValue) => validatedDateString + (initialValue.includes(dateTimeSeparator) ? dateTimeSeparator : ''), }), createMinMaxDateTimePostprocessor({ min, max, dateModeTemplate, timeMode, dateTimeSeparator, }), ], plugins: [ createTimeSegmentsSteppingPlugin({ step: timeStep, fullMode, timeSegmentMinValues, timeSegmentMaxValues, }), createMeridiemSteppingPlugin(fullMode.indexOf('AA')), ], }; } ================================================ FILE: projects/kit/src/lib/masks/date-time/date-time-params.ts ================================================ import type {MaskitoDateMode, MaskitoTimeMode} from '../../types'; export interface MaskitoDateTimeParams { dateMode: MaskitoDateMode; timeMode: MaskitoTimeMode; dateSeparator?: string; max?: Date; min?: Date; dateTimeSeparator?: string; timeStep?: number; } ================================================ FILE: projects/kit/src/lib/masks/date-time/index.ts ================================================ export * from './constants'; export * from './date-time-mask'; export * from './date-time-params'; export * from './postprocessors'; export * from './preprocessors'; export * from './utils'; ================================================ FILE: projects/kit/src/lib/masks/date-time/postprocessors/index.ts ================================================ export * from './min-max-date-time-postprocessor'; ================================================ FILE: projects/kit/src/lib/masks/date-time/postprocessors/min-max-date-time-postprocessor.ts ================================================ import type {MaskitoPostprocessor} from '@maskito/core'; import {DEFAULT_MAX_DATE, DEFAULT_MIN_DATE} from '../../../constants'; import type {MaskitoTimeMode} from '../../../types'; import { clamp, dateToSegments, isDateStringComplete, parseDateString, segmentsToDate, toDateString, } from '../../../utils'; import {raiseSegmentValueToMin} from '../../../utils/date/raise-segment-value-to-min'; import {parseTimeString} from '../../../utils/time'; import {isDateTimeStringComplete, splitDateTimeString} from '../utils'; export function createMinMaxDateTimePostprocessor({ dateModeTemplate, timeMode, min = DEFAULT_MIN_DATE, max = DEFAULT_MAX_DATE, dateTimeSeparator, }: { dateModeTemplate: string; timeMode: MaskitoTimeMode; min?: Date; max?: Date; dateTimeSeparator: string; }): MaskitoPostprocessor { return ({value, selection}) => { const [dateString, timeString] = splitDateTimeString(value, dateModeTemplate); const parsedDate = parseDateString(dateString, dateModeTemplate); const parsedTime = parseTimeString(timeString, timeMode); if ( !isDateTimeStringComplete(value, { dateMode: dateModeTemplate, timeMode, dateTimeSeparator, }) ) { const fixedDate = raiseSegmentValueToMin(parsedDate, dateModeTemplate); const {year, month, day} = isDateStringComplete(dateString, dateModeTemplate) ? dateToSegments(clamp(segmentsToDate(fixedDate), min, max)) : fixedDate; const fixedValue = toDateString( { year, month, day, ...parsedTime, }, {dateMode: dateModeTemplate, dateTimeSeparator, timeMode}, ); const tail = value.slice(fixedValue.length); return { selection, value: `${fixedValue}${tail}`, }; } const date = segmentsToDate(parsedDate, parsedTime); const clampedDate = clamp(date, min, max); // trailing segment separators or meridiem characters const [trailingNonDigitCharacters = ''] = value.match(/\D+$/g) || []; const validatedValue = `${toDateString(dateToSegments(clampedDate), { dateMode: dateModeTemplate, dateTimeSeparator, timeMode, })}${trailingNonDigitCharacters}`; return { selection, value: validatedValue, }; }; } ================================================ FILE: projects/kit/src/lib/masks/date-time/preprocessors/index.ts ================================================ export * from './valid-date-time-preprocessor'; ================================================ FILE: projects/kit/src/lib/masks/date-time/preprocessors/valid-date-time-preprocessor.ts ================================================ import type {MaskitoPreprocessor} from '@maskito/core'; import type {MaskitoTimeMode, MaskitoTimeSegments} from '../../../types'; import {validateDateString} from '../../../utils'; import {enrichTimeSegmentsWithZeroes} from '../../../utils/time'; import {splitDateTimeString} from '../utils'; export function createValidDateTimePreprocessor({ dateModeTemplate, dateSegmentsSeparator, dateTimeSeparator, timeMode, timeSegmentMaxValues, }: { dateModeTemplate: string; dateSegmentsSeparator: string; dateTimeSeparator: string; timeMode: MaskitoTimeMode; timeSegmentMaxValues: MaskitoTimeSegments; }): MaskitoPreprocessor { return ({elementState, data}) => { const {value, selection} = elementState; if (data === dateSegmentsSeparator) { return { elementState, data: selection[0] === value.length ? data : '', }; } const newCharacters = data.replaceAll(/\D/g, ''); if (!newCharacters) { return {elementState, data}; } const [from, rawTo] = selection; let to = rawTo + data.length; const newPossibleValue = `${value.slice(0, from)}${newCharacters}${value.slice(to)}`; const [dateString, timeString] = splitDateTimeString( newPossibleValue, dateModeTemplate, ); let validatedValue = ''; const hasDateTimeSeparator = newPossibleValue.includes(dateTimeSeparator); const {validatedDateString, updatedSelection} = validateDateString({ dateString, dateSegmentsSeparator, dateModeTemplate, offset: 0, selection: [from, to], }); if (dateString && !validatedDateString) { return {elementState, data: ''}; // prevent changes } to = updatedSelection[1]; validatedValue += validatedDateString; const updatedTimeState = enrichTimeSegmentsWithZeroes( {value: timeString, selection: [from, to]}, {mode: timeMode, timeSegmentMaxValues}, ); to = updatedTimeState.selection[1]; validatedValue += hasDateTimeSeparator ? `${dateTimeSeparator}${updatedTimeState.value}` : updatedTimeState.value; const newData = validatedValue.slice(from, to); return { elementState: { selection, value: `${validatedValue.slice(0, from)}${newData .split(dateSegmentsSeparator) .map((segment) => '0'.repeat(segment.length)) .join(dateSegmentsSeparator)}${validatedValue.slice(to)}`, }, data: newData, }; }; } ================================================ FILE: projects/kit/src/lib/masks/date-time/tests/date-segments-zero-padding.spec.ts ================================================ import {beforeEach, describe, expect, it} from '@jest/globals'; import { MASKITO_DEFAULT_OPTIONS, type MaskitoOptions, maskitoTransform, } from '@maskito/core'; import {maskitoDateTimeOptionsGenerator} from '@maskito/kit'; describe('DateTime (maskitoTransform) | Date segments zero padding', () => { describe('[dateMode]="dd/mm/yyyy" & [timeMode]="HH:MM:SS.MSS"', () => { let options: MaskitoOptions = MASKITO_DEFAULT_OPTIONS; beforeEach(() => { options = maskitoDateTimeOptionsGenerator({ dateMode: 'dd/mm/yyyy', timeMode: 'HH:MM:SS.MSS', dateSeparator: '/', }); }); describe('pads digits with zero if date segment exceeds its max possible value', () => { describe('pads digit > 1 with zero for months', () => { [0, 1].forEach((digit) => { it(`01/${digit} => 01/${digit}`, () => { expect(maskitoTransform(`01${digit}`, options)).toBe( `01/${digit}`, ); }); }); [2, 3, 4, 5, 6, 7, 8, 9].forEach((digit) => { it(`01/${digit} => 01/0${digit}`, () => { expect(maskitoTransform(`01${digit}`, options)).toBe( `01/0${digit}`, ); }); }); }); describe('pads digit > 3 with zero for days', () => { [0, 1, 2, 3].forEach((digit) => { it(`${digit} => ${digit}`, () => { expect(maskitoTransform(`${digit}`, options)).toBe(`${digit}`); }); }); [4, 5, 6, 7, 8, 9].forEach((digit) => { it(`${digit} => 0${digit}`, () => { expect(maskitoTransform(`${digit}`, options)).toBe(`0${digit}`); }); }); }); }); }); }); ================================================ FILE: projects/kit/src/lib/masks/date-time/tests/date-time-separator.spec.ts ================================================ import {beforeEach, describe, expect, it} from '@jest/globals'; import {MASKITO_DEFAULT_OPTIONS, maskitoTransform} from '@maskito/core'; import type {MaskitoTimeMode} from '../../../types'; import {maskitoDateTimeOptionsGenerator} from '../date-time-mask'; describe('DateTime | dateTimeSeparator', () => { const dateTimeSeparators = [':', ';_', '_-_', '_at_']; let options = MASKITO_DEFAULT_OPTIONS; dateTimeSeparators.forEach((dateTimeSeparator) => { const testCases: Array<{ typedDigits: string; formattedValue: string; timeMode: MaskitoTimeMode; }> = [ { typedDigits: '050220040341', formattedValue: `05.02.2004${dateTimeSeparator}03:41`, timeMode: 'HH:MM', }, { typedDigits: '10062007034111', formattedValue: `10.06.2007${dateTimeSeparator}03:41:11`, timeMode: 'HH:MM:SS', }, { typedDigits: '15081999034111111', formattedValue: `15.08.1999${dateTimeSeparator}03:41:11.111`, timeMode: 'HH:MM:SS.MSS', }, ]; describe(`correctly applies "${dateTimeSeparator}" as dateTimeSeparator`, () => { testCases.forEach(({typedDigits, formattedValue, timeMode}) => { beforeEach(() => { options = maskitoDateTimeOptionsGenerator({ dateMode: 'dd/mm/yyyy', timeMode, dateTimeSeparator, }); }); it(`${typedDigits} => ${formattedValue}`, () => { expect(maskitoTransform(typedDigits, options)).toBe(formattedValue); }); }); }); }); }); ================================================ FILE: projects/kit/src/lib/masks/date-time/tests/pseudo-date-end-separator.spec.ts ================================================ import {beforeEach, describe, expect, it} from '@jest/globals'; import { MASKITO_DEFAULT_OPTIONS, type MaskitoOptions, maskitoTransform, } from '@maskito/core'; import {maskitoDateTimeOptionsGenerator} from '@maskito/kit'; import {DATE_TIME_SEPARATOR} from '../constants'; describe('DateTime (maskitoTransform) | Pseudo date end separators', () => { let options: MaskitoOptions = MASKITO_DEFAULT_OPTIONS; beforeEach(() => { options = maskitoDateTimeOptionsGenerator({ dateMode: 'dd/mm/yyyy', timeMode: 'HH:MM:SS.MSS', dateSeparator: '.', }); }); it('works with already valid range separator', () => { expect(maskitoTransform(`01012000${DATE_TIME_SEPARATOR}235959999`, options)).toBe( `01.01.2000${DATE_TIME_SEPARATOR}23:59:59.999`, ); }); it('replaces space with valid date end separator', () => { expect(maskitoTransform('01012000 ', options)).toBe( `01.01.2000${DATE_TIME_SEPARATOR}`, ); expect(maskitoTransform('01012000 2359', options)).toBe( `01.01.2000${DATE_TIME_SEPARATOR}23:59`, ); }); it('replaces comma with valid range separator', () => { expect(maskitoTransform('01012000,', options)).toBe( `01.01.2000${DATE_TIME_SEPARATOR}`, ); expect(maskitoTransform('01012000,235959999', options)).toBe( `01.01.2000${DATE_TIME_SEPARATOR}23:59:59.999`, ); }); it('does not add anything if separator does not initially exist', () => { expect(maskitoTransform('01012000', options)).toBe('01.01.2000'); }); }); ================================================ FILE: projects/kit/src/lib/masks/date-time/utils/index.ts ================================================ export * from './is-date-time-string-complete'; export * from './parse-date-time'; export * from './split-date-time-string'; export * from './stringify-date-time'; ================================================ FILE: projects/kit/src/lib/masks/date-time/utils/is-date-time-string-complete.ts ================================================ import type {MaskitoTimeMode} from '../../../types'; import {DATE_TIME_SEPARATOR} from '../constants'; export function isDateTimeStringComplete( dateTimeString: string, { dateMode, timeMode, dateTimeSeparator = DATE_TIME_SEPARATOR, }: { dateMode: string; timeMode: MaskitoTimeMode; dateTimeSeparator: string; }, ): boolean { return ( dateTimeString.length >= dateMode.length + timeMode.length + dateTimeSeparator.length && (dateTimeString.split(dateTimeSeparator)[0] ?? '') .split(/\D/) .every((segment) => !/^0+$/.exec(segment)) ); } ================================================ FILE: projects/kit/src/lib/masks/date-time/utils/parse-date-time.ts ================================================ import {DEFAULT_MAX_DATE, DEFAULT_MIN_DATE} from '../../../constants'; import {clamp} from '../../../utils'; import {maskitoParseDate} from '../../date/utils'; import {maskitoParseTime} from '../../time'; import {DATE_TIME_SEPARATOR} from '../constants'; import type {MaskitoDateTimeParams} from '../date-time-params'; export function maskitoParseDateTime( value: string, { dateMode, timeMode, min = DEFAULT_MIN_DATE, max = DEFAULT_MAX_DATE, dateTimeSeparator = DATE_TIME_SEPARATOR, }: MaskitoDateTimeParams, ): Date | null { const [dateSegment = '', timeSegment = ''] = value.split(dateTimeSeparator); const digitsPattern = timeMode.replaceAll(/[^HMS]/g, ''); const digits = timeSegment.replaceAll(/\D+/g, ''); if (digits.length !== digitsPattern.length) { return null; } const date = maskitoParseDate(dateSegment, {mode: dateMode}); const time = maskitoParseTime(timeSegment, {mode: timeMode}); if (!date) { return null; } const dateTime = new Date(Number(date) + time); return dateMode.includes('y') ? clamp(dateTime, min, max) : dateTime; } ================================================ FILE: projects/kit/src/lib/masks/date-time/utils/split-date-time-string.ts ================================================ const NON_DIGIT_PLACEHOLDER_RE = /[^dmy]/g; const LEADING_NON_DIGIT_RE = /^\D*/; export function splitDateTimeString( dateTime: string, dateModeTemplate: string, ): [date: string, time: string] { const dateDigitsCount = dateModeTemplate.replaceAll( NON_DIGIT_PLACEHOLDER_RE, '', ).length; const [date = ''] = new RegExp(String.raw`(\d[^\d]*){0,${dateDigitsCount - 1}}\d?`).exec(dateTime) || []; const [dateTimeSeparator = ''] = LEADING_NON_DIGIT_RE.exec(dateTime.slice(date.length)) || []; return [date, dateTime.slice(date.length + dateTimeSeparator.length)]; } ================================================ FILE: projects/kit/src/lib/masks/date-time/utils/stringify-date-time.ts ================================================ import {DEFAULT_MAX_DATE, DEFAULT_MIN_DATE} from '../../../constants'; import {clamp} from '../../../utils'; import {maskitoStringifyDate} from '../../date/utils'; import {maskitoStringifyTime} from '../../time'; import {DATE_TIME_SEPARATOR} from '../constants'; import type {MaskitoDateTimeParams} from '../date-time-params'; export function maskitoStringifyDateTime( date: Date, { dateMode, timeMode, dateTimeSeparator = DATE_TIME_SEPARATOR, dateSeparator = '.', min = DEFAULT_MIN_DATE, max = DEFAULT_MAX_DATE, }: MaskitoDateTimeParams, ): string { const validatedDate = clamp(date, min, max); const dateString = maskitoStringifyDate(validatedDate, { mode: dateMode, separator: dateSeparator, min, max, }); const extractedTime = Number(validatedDate) - Number( new Date( validatedDate.getFullYear(), validatedDate.getMonth(), validatedDate.getDate(), ), ); const timeString = maskitoStringifyTime(extractedTime, {mode: timeMode}); return `${dateString}${dateTimeSeparator}${timeString}`; } ================================================ FILE: projects/kit/src/lib/masks/date-time/utils/tests/parse-date-time.spec.ts ================================================ import {DEFAULT_MAX_DATE, DEFAULT_MIN_DATE} from '../../../../constants'; import type {MaskitoDateTimeParams} from '../../date-time-params'; import {maskitoParseDateTime} from '../parse-date-time'; describe('maskitoParseDateTime', () => { const dateMode = 'dd/mm/yyyy'; const timeMode = 'HH:MM'; const dateTimeSeparator = ', '; it('returns null for incomplete date-time string', () => { expect( maskitoParseDateTime('02/11/2018', {dateMode, timeMode, dateTimeSeparator}), ).toBeNull(); expect( maskitoParseDateTime('16:20', {dateMode, timeMode, dateTimeSeparator}), ).toBeNull(); }); it('parses valid date-time string', () => { expect( maskitoParseDateTime('02/11/2018, 16:20', { dateMode, timeMode, dateTimeSeparator, }), ).toEqual(new Date(2018, 10, 2, 16, 20)); }); it('clamps date-time to min and max bounds', () => { const min = new Date(2020, 0, 1, 10, 1); const max = new Date(2025, 11, 31, 11, 1); expect( maskitoParseDateTime('01/01/2019, 10:00', { dateMode, timeMode, dateTimeSeparator, min, max, }), ).toEqual(min); expect( maskitoParseDateTime('01/01/2030, 10:00', { dateMode, timeMode, dateTimeSeparator, min, max, }), ).toEqual(max); }); it('handles default min and max bounds', () => { expect( maskitoParseDateTime('01/01/0001, 00:00', { dateMode, timeMode, dateTimeSeparator, }), ).toEqual(DEFAULT_MIN_DATE); expect( maskitoParseDateTime('31/12/9999, 23:59:59.999', { dateMode, timeMode: 'HH:MM:SS.MSS', dateTimeSeparator, }), ).toEqual(DEFAULT_MAX_DATE); }); it('parses date-time with custom separator', () => { const customSeparator = 'T'; expect( maskitoParseDateTime('02/11/2018T16:20', { dateMode, timeMode, dateTimeSeparator: customSeparator, }), ).toEqual(new Date(2018, 10, 2, 16, 20)); }); it('returns null for missing date-time separator', () => { expect( maskitoParseDateTime('02/11/201816:20', { dateMode, timeMode, dateTimeSeparator, }), ).toBeNull(); }); it('handles edge cases for leap years', () => { expect( maskitoParseDateTime('29/02/2020, 12:00', { dateMode, timeMode, dateTimeSeparator, }), ).toEqual(new Date(2020, 1, 29, 12, 0)); expect( maskitoParseDateTime('29/02/2019, 12:00', { dateMode, timeMode, dateTimeSeparator, }), ).toEqual(new Date(2019, 2, 1, 12, 0)); }); it('handles edge cases for time boundaries', () => { expect( maskitoParseDateTime('31/12/2018, 00:00', { dateMode, timeMode, dateTimeSeparator, }), ).toEqual(new Date(2018, 11, 31, 0, 0)); expect( maskitoParseDateTime('31/12/2018, 23:59', { dateMode, timeMode, dateTimeSeparator, }), ).toEqual(new Date(2018, 11, 31, 23, 59)); }); it('returns null for empty or whitespace-only input', () => { expect( maskitoParseDateTime('', {dateMode, timeMode, dateTimeSeparator}), ).toBeNull(); expect( maskitoParseDateTime(' ', {dateMode, timeMode, dateTimeSeparator}), ).toBeNull(); }); it('parses date-time with dd/mm date mode without year', () => { expect( maskitoParseDateTime('25/12, 16:20', { dateMode: 'dd/mm', timeMode, dateTimeSeparator, })?.getTime(), ).toBe(Date.parse('0000-12-25T16:20:00.000')); }); it('parses date-time with mm/dd date mode without year', () => { expect( maskitoParseDateTime('12/25, 16:20', { dateMode: 'mm/dd', timeMode, dateTimeSeparator, })?.getTime(), ).toBe(Date.parse('0000-12-25T16:20:00.000')); }); it('handles invalid date-time separator', () => { expect( maskitoParseDateTime('31/12/2018-16:20', { dateMode, timeMode, dateTimeSeparator, }), ).toBeNull(); expect( maskitoParseDateTime('31/12/2018 16:20', { dateMode, timeMode, dateTimeSeparator, }), ).toBeNull(); }); it('handles mixed valid and invalid inputs', () => { expect( maskitoParseDateTime('31/12/2018, invalid', { dateMode, timeMode, dateTimeSeparator, }), ).toBeNull(); expect( maskitoParseDateTime('invalid, 16:20', { dateMode, timeMode, dateTimeSeparator, }), ).toBeNull(); }); describe('invalid date-time strings', () => { const params: MaskitoDateTimeParams = {dateMode, timeMode, dateTimeSeparator}; it.each([ 'this-is-not-a-datetime', '', ' ', '02//2018, 16:20', '/11/2018, 16:20', '02/11/, 16:20', '02/11/20, 16:20', '02/11/abcd, 16:20', '1a/11/2018, 16:20', ])('should return null for invalid date part "%s"', (value) => expect(maskitoParseDateTime(value, params)).toBeNull(), ); it.each(['02/11/2018, 16:ab', '02/11/2018, aa:20'])( 'should return null for invalid time part "%s"', (value) => expect(maskitoParseDateTime(value, params)).toBeNull(), ); }); }); ================================================ FILE: projects/kit/src/lib/masks/date-time/utils/tests/split-date-time-string.spec.ts ================================================ import {splitDateTimeString} from '../split-date-time-string'; describe('splitDateTimeString', () => { describe('dd.mm.yyyy', () => { const split = (value: string): [string, string] => splitDateTimeString(value, 'dd.mm.yyyy'); ( [ {input: '', output: ['', '']}, {input: '02', output: ['02', '']}, {input: '02.', output: ['02.', '']}, {input: '0211', output: ['0211', '']}, {input: '0211.', output: ['0211.', '']}, {input: '02.112018', output: ['02.112018', '']}, {input: '02.112018,', output: ['02.112018', '']}, {input: '02.112018, ', output: ['02.112018', '']}, {input: '02.11.2018, ', output: ['02.11.2018', '']}, {input: '021120181620', output: ['02112018', '1620']}, {input: '02112018,1620', output: ['02112018', '1620']}, {input: '02112018, 1620', output: ['02112018', '1620']}, {input: '02112018, 16:20', output: ['02112018', '16:20']}, {input: '02112018,16:20', output: ['02112018', '16:20']}, {input: '02.11.2018,1620', output: ['02.11.2018', '1620']}, {input: '02.11.2018, 1620', output: ['02.11.2018', '1620']}, {input: '02.11.2018, 16:20', output: ['02.11.2018', '16:20']}, {input: '02.11.2018,16:20', output: ['02.11.2018', '16:20']}, ] as const ).forEach(({input, output}) => { it(`${input} -> ${JSON.stringify(output)}`, () => { expect(split(input)).toEqual(output); }); }); }); describe('dd. mm. yyyy (date segment separator consists of space and dot)', () => { const parse = (value: string): [string, string] => splitDateTimeString(value, 'dd. mm. yyyy'); ( [ {input: '', output: ['', '']}, {input: '02', output: ['02', '']}, {input: '02.', output: ['02.', '']}, {input: '02. ', output: ['02. ', '']}, {input: '0211', output: ['0211', '']}, {input: '0211. ', output: ['0211. ', '']}, {input: '02. 112018', output: ['02. 112018', '']}, {input: '02. 112018,', output: ['02. 112018', '']}, {input: '02. 112018, ', output: ['02. 112018', '']}, {input: '02. 11. 2018, ', output: ['02. 11. 2018', '']}, {input: '02. 11. 2018,1620', output: ['02. 11. 2018', '1620']}, {input: '02. 11. 2018, 1620', output: ['02. 11. 2018', '1620']}, {input: '02. 11. 2018, 16:20', output: ['02. 11. 2018', '16:20']}, {input: '02. 11. 2018,16:20', output: ['02. 11. 2018', '16:20']}, ] as const ).forEach(({input, output}) => { it(`${input} -> ${JSON.stringify(output)}`, () => { expect(parse(input)).toEqual(output); }); }); }); }); ================================================ FILE: projects/kit/src/lib/masks/date-time/utils/tests/stringify-date-time.spec.ts ================================================ import {maskitoStringifyDateTime} from '../stringify-date-time'; describe('maskitoStringifyDateTime', () => { const date = new Date(2025, 3, 11, 15, 30, 45); const dateMode = 'dd/mm/yyyy'; const timeMode = 'HH:MM:SS'; const dateSeparator = '/'; const dateTimeSeparator = ', '; it('should stringify date and time with default separator', () => { const result = maskitoStringifyDateTime(date, { dateMode, timeMode, dateSeparator, dateTimeSeparator, }); expect(result).toBe('11/04/2025, 15:30:45'); }); it('should stringify date and time with custom separator', () => { const result = maskitoStringifyDateTime(date, { dateMode, timeMode, dateSeparator, dateTimeSeparator: ' | ', }); expect(result).toBe('11/04/2025 | 15:30:45'); }); it('should clamp date to min boundary', () => { const minDate = new Date('2025-04-12T00:00:00.000'); const result = maskitoStringifyDateTime(date, { dateMode, timeMode, dateSeparator, min: minDate, }); expect(result).toBe('12/04/2025, 00:00:00'); }); it('should clamp date to max boundary', () => { const maxDate = new Date('2025-04-10T23:59:59.999'); const result = maskitoStringifyDateTime(date, { dateMode, timeMode, dateSeparator, max: maxDate, }); expect(result).toBe('10/04/2025, 23:59:59'); }); it('should handle edge cases for leap years', () => { const leapYearDate = new Date('2024-02-29T12:00:00.000'); const result = maskitoStringifyDateTime(leapYearDate, { dateMode, timeMode, dateSeparator, }); expect(result).toBe('29/02/2024, 12:00:00'); }); it('should handle edge cases for time boundaries', () => { const midnight = new Date('2025-04-11T00:00:00.000'); const resultMidnight = maskitoStringifyDateTime(midnight, { dateMode, timeMode, dateSeparator, }); expect(resultMidnight).toBe('11/04/2025, 00:00:00'); const endOfDay = new Date('2025-04-11T23:59:59.999'); const resultEndOfDay = maskitoStringifyDateTime(endOfDay, { dateMode, timeMode, dateSeparator, }); expect(resultEndOfDay).toBe('11/04/2025, 23:59:59'); }); }); ================================================ FILE: projects/kit/src/lib/masks/number/index.ts ================================================ export * from './number-mask'; export * from './number-params'; export * from './plugins'; export * from './processors'; export * from './utils'; /* Don't put it inside `./utils` entrypoint to avoid circular dependency - `stringify-number.ts` uses `maskitoNumberOptionsGenerator` - almost all processors and plugins (which are part of `maskitoNumberOptionsGenerator`) uses `./utils` */ export * from './utils/stringify-number'; ================================================ FILE: projects/kit/src/lib/masks/number/number-mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import { createFullWidthToHalfWidthPreprocessor, maskitoPostfixPostprocessorGenerator, } from '../../processors'; import {type MaskitoNumberParams} from './number-params'; import { createLeadingZeroesValidationPlugin, createMinMaxPlugin, createNotEmptyIntegerPlugin, } from './plugins'; import { createAffixesFilterPreprocessor, createDecimalZeroPaddingPostprocessor, createInitializationOnlyPreprocessor, createLeadingMinusDeletionPreprocessor, createMinMaxPostprocessor, createNonRemovableCharsDeletionPreprocessor, createNotEmptyIntegerPartPreprocessor, createNumberPrefixPostprocessor, createPseudoCharactersPreprocessor, createRepeatedDecimalSeparatorPreprocessor, createThousandSeparatorPostprocessor, createZeroPrecisionPreprocessor, emptyPostprocessor, } from './processors'; import {generateMaskExpression, withNumberDefaults} from './utils'; export function maskitoNumberOptionsGenerator( optionalParams?: MaskitoNumberParams, ): Required { const params = withNumberDefaults(optionalParams); return { mask: generateMaskExpression(params), preprocessors: [ createFullWidthToHalfWidthPreprocessor(), createInitializationOnlyPreprocessor(params), createAffixesFilterPreprocessor(params), createPseudoCharactersPreprocessor({ ...params, validCharacter: params.minusSign, pseudoCharacters: params.minusPseudoSigns, }), createPseudoCharactersPreprocessor({ ...params, validCharacter: params.decimalSeparator, pseudoCharacters: params.decimalPseudoSeparators, }), createNotEmptyIntegerPartPreprocessor(params), createNonRemovableCharsDeletionPreprocessor(params), createZeroPrecisionPreprocessor(params), createRepeatedDecimalSeparatorPreprocessor(params), createLeadingMinusDeletionPreprocessor(params), ], postprocessors: [ createMinMaxPostprocessor(params), createNumberPrefixPostprocessor(params), maskitoPostfixPostprocessorGenerator(params.postfix), createThousandSeparatorPostprocessor(params), createDecimalZeroPaddingPostprocessor(params), emptyPostprocessor(params), ], plugins: [ createLeadingZeroesValidationPlugin(params), createNotEmptyIntegerPlugin(params), createMinMaxPlugin(params), ], overwriteMode: params.minimumFractionDigits > 0 ? ({value, selection: [from]}) => from <= value.indexOf(params.decimalSeparator) ? 'shift' : 'replace' : 'shift', }; } ================================================ FILE: projects/kit/src/lib/masks/number/number-params.ts ================================================ export interface MaskitoNumberParams extends Pick< Intl.NumberFormatOptions, 'maximumFractionDigits' | 'minimumFractionDigits' > { min?: bigint | number; max?: bigint | number; decimalSeparator?: string; decimalPseudoSeparators?: readonly string[]; thousandSeparator?: string; thousandSeparatorPattern?: (digits: string) => readonly string[]; prefix?: string; postfix?: string; minusSign?: string; minusPseudoSigns?: readonly string[]; negativePattern?: 'minusFirst' | 'prefixFirst'; } ================================================ FILE: projects/kit/src/lib/masks/number/plugins/index.ts ================================================ export * from './leading-zeroes-validation.plugin'; export * from './min-max.plugin'; export * from './not-empty-integer.plugin'; ================================================ FILE: projects/kit/src/lib/masks/number/plugins/leading-zeroes-validation.plugin.ts ================================================ import {type MaskitoPlugin, maskitoUpdateElement} from '@maskito/core'; import {maskitoEventHandler} from '../../../plugins'; import type {MaskitoNumberParams} from '../number-params'; import {createLeadingZeroesValidationPostprocessor} from '../processors'; const DUMMY_SELECTION = [0, 0] as const; /** * It removes repeated leading zeroes for integer part on blur-event. * @example 000000 => blur => 0 * @example 00005 => blur => 5 */ export function createLeadingZeroesValidationPlugin( params: Pick< Required, | 'decimalPseudoSeparators' | 'decimalSeparator' | 'maximumFractionDigits' | 'minusPseudoSigns' | 'minusSign' | 'negativePattern' | 'postfix' | 'prefix' | 'thousandSeparator' >, ): MaskitoPlugin { const dropRepeatedLeadingZeroes = createLeadingZeroesValidationPostprocessor(params); return maskitoEventHandler( 'blur', (element) => { const newValue = dropRepeatedLeadingZeroes( { value: element.value, selection: DUMMY_SELECTION, }, {value: '', selection: DUMMY_SELECTION}, ).value; maskitoUpdateElement(element, newValue); }, {capture: true}, ); } ================================================ FILE: projects/kit/src/lib/masks/number/plugins/min-max.plugin.ts ================================================ import {type MaskitoPlugin, maskitoTransform, maskitoUpdateElement} from '@maskito/core'; import {maskitoEventHandler} from '../../../plugins'; import {clamp} from '../../../utils'; import type {MaskitoNumberParams} from '../number-params'; import {maskitoParseNumber, stringifyNumberWithoutExp} from '../utils'; /** * This plugin is connected with {@link createMinMaxPostprocessor}: * both validate `min`/`max` bounds of entered value (but at the different point of time). */ export function createMinMaxPlugin(params: Required): MaskitoPlugin { const {decimalSeparator, maximumFractionDigits, min, max} = params; return maskitoEventHandler( 'blur', (element, options) => { const parsedNumber = maskitoParseNumber(element.value, { ...params, bigint: !maximumFractionDigits && !element.value.includes(decimalSeparator), }) ?? Number.NaN; const clampedNumber = clamp(parsedNumber, min, max); if (!Number.isNaN(parsedNumber) && parsedNumber !== clampedNumber) { maskitoUpdateElement( element, maskitoTransform(stringifyNumberWithoutExp(clampedNumber), options), ); } }, {capture: true}, ); } ================================================ FILE: projects/kit/src/lib/masks/number/plugins/not-empty-integer.plugin.ts ================================================ import {type MaskitoPlugin, maskitoUpdateElement} from '@maskito/core'; import {maskitoEventHandler} from '../../../plugins'; import {escapeRegExp, noop} from '../../../utils'; import type {MaskitoNumberParams} from '../number-params'; import {fromNumberParts, toNumberParts} from '../utils'; /** * It pads EMPTY integer part with zero if decimal parts exists. * It works on blur event only! * @example 1|,23 => Backspace => Blur => 0,23 */ export function createNotEmptyIntegerPlugin( params: Pick< Required, | 'decimalPseudoSeparators' | 'decimalSeparator' | 'maximumFractionDigits' | 'minusPseudoSigns' | 'minusSign' | 'negativePattern' | 'postfix' | 'prefix' >, ): MaskitoPlugin { const {decimalSeparator} = params; if (!decimalSeparator) { return noop; } return maskitoEventHandler( 'blur', (element) => { const {prefix, postfix, ...numberParts} = toNumberParts( element.value, params, ); const onlyNumber = fromNumberParts(numberParts, params).replace( new RegExp(String.raw`^(\D+)?${escapeRegExp(decimalSeparator)}`), `$10${decimalSeparator}`, ); const newValue = fromNumberParts( { ...toNumberParts(onlyNumber, params), prefix, postfix, }, params, ); maskitoUpdateElement(element, newValue); }, {capture: true}, ); } ================================================ FILE: projects/kit/src/lib/masks/number/processors/affixes-filter-preprocessor.ts ================================================ import type {MaskitoPreprocessor} from '@maskito/core'; import type {MaskitoNumberParams} from '../number-params'; import {fromNumberParts, toNumberParts} from '../utils'; /** * It drops prefix and postfix from data * Needed for case, when prefix or postfix contain decimalSeparator, to ignore it in resulting number * @example User pastes '{prefix}123.45{postfix}' => 123.45 */ export function createAffixesFilterPreprocessor( params: Pick< Required, | 'decimalPseudoSeparators' | 'decimalSeparator' | 'maximumFractionDigits' | 'minusPseudoSigns' | 'minusSign' | 'negativePattern' | 'postfix' | 'prefix' >, ): MaskitoPreprocessor { return ({elementState, data}) => { const {value} = elementState; const {prefix, postfix, ...numberParts} = toNumberParts(data, params); return { elementState, data: fromNumberParts( { ...numberParts, prefix: value.startsWith(prefix) ? '' : prefix, postfix: value.endsWith(postfix) ? '' : postfix, }, params, ), }; }; } ================================================ FILE: projects/kit/src/lib/masks/number/processors/decimal-zero-padding-postprocessor.ts ================================================ import type {MaskitoPostprocessor} from '@maskito/core'; import {identity} from '../../../utils'; import type {MaskitoNumberParams} from '../number-params'; import {fromNumberParts, maskitoParseNumber, toNumberParts} from '../utils'; /** * If `minimumFractionDigits` is `>0`, it pads decimal part with zeroes * (until number of digits after decimalSeparator is equal to the `minimumFractionDigits`). * @example 1,42 => (`minimumFractionDigits` is equal to 4) => 1,4200. */ export function createDecimalZeroPaddingPostprocessor( params: Pick< Required, | 'decimalPseudoSeparators' | 'decimalSeparator' | 'maximumFractionDigits' | 'minimumFractionDigits' | 'minusPseudoSigns' | 'minusSign' | 'negativePattern' | 'postfix' | 'prefix' >, ): MaskitoPostprocessor { const {minimumFractionDigits} = params; if (!minimumFractionDigits) { return identity; } return ({value, selection}) => { if (Number.isNaN(maskitoParseNumber(value, params))) { return {value, selection}; } const {decimalPart, ...numberParts} = toNumberParts(value, params); return { value: fromNumberParts( { ...numberParts, decimalPart: decimalPart.padEnd(minimumFractionDigits, '0'), }, params, ), selection, }; }; } ================================================ FILE: projects/kit/src/lib/masks/number/processors/empty-postprocessor.ts ================================================ import type {MaskitoPostprocessor} from '@maskito/core'; import type {MaskitoNumberParams} from '../number-params'; import {fromNumberParts, toNumberParts} from '../utils'; /** * Make textfield empty if there is no integer part and all decimal digits are zeroes. * @example 0|,00 => Backspace => Empty. * @example -0|,00 => Backspace => -. * @example ,42| => Backspace x2 => ,|00 => Backspace => Empty */ export function emptyPostprocessor( params: Pick< Required, | 'decimalPseudoSeparators' | 'decimalSeparator' | 'maximumFractionDigits' | 'minusPseudoSigns' | 'minusSign' | 'negativePattern' | 'postfix' | 'prefix' >, ): MaskitoPostprocessor { return ({value, selection}) => { const [caretIndex] = selection; const {prefix, minus, integerPart, decimalSeparator, decimalPart, postfix} = toNumberParts(value, params); const aloneDecimalSeparator = !integerPart && !decimalPart && decimalSeparator; if ( (!integerPart && !Number(decimalPart) && caretIndex === `${minus}${prefix}`.length) || aloneDecimalSeparator ) { return { selection, value: fromNumberParts({prefix, minus, postfix}, params), }; } return {value, selection}; }; } ================================================ FILE: projects/kit/src/lib/masks/number/processors/index.ts ================================================ export * from './affixes-filter-preprocessor'; export * from './decimal-zero-padding-postprocessor'; export * from './empty-postprocessor'; export * from './initialization-only-preprocessor'; export * from './leading-minus-deletion-preprocessor'; export * from './leading-zeroes-validation-postprocessor'; export * from './min-max-postprocessor'; export * from './non-removable-chars-deletion-preprocessor'; export * from './not-empty-integer-part-preprocessor'; export * from './number-prefix-postprocessor'; export * from './pseudo-character-preprocessor'; export * from './repeated-decimal-separator-preprocessor'; export * from './thousand-separator-postprocessor'; export * from './zero-precision-preprocessor'; ================================================ FILE: projects/kit/src/lib/masks/number/processors/initialization-only-preprocessor.ts ================================================ import {type MaskitoPreprocessor, maskitoTransform} from '@maskito/core'; import type {MaskitoNumberParams} from '../number-params'; import {fromNumberParts, generateMaskExpression, toNumberParts} from '../utils'; /** * This preprocessor works only once at initialization phase (when `new Maskito(...)` is executed). * This preprocessor helps to avoid conflicts during transition from one mask to another (for the same input). * For example, the developer changes postfix (or other mask's props) during run-time. * ``` * let maskitoOptions = maskitoNumberOptionsGenerator({postfix: ' year'}); * // [3 seconds later] * maskitoOptions = maskitoNumberOptionsGenerator({postfix: ' years'}); * ``` */ export function createInitializationOnlyPreprocessor( params: Pick< Required, | 'decimalPseudoSeparators' | 'decimalSeparator' | 'maximumFractionDigits' | 'minusPseudoSigns' | 'minusSign' | 'negativePattern' | 'postfix' | 'prefix' >, ): MaskitoPreprocessor { let isInitializationPhase = true; const cleanNumberMask = generateMaskExpression({ ...params, prefix: '', postfix: '', thousandSeparator: '', maximumFractionDigits: Infinity, min: -Infinity, }); return ({elementState, data}) => { if (!isInitializationPhase) { return {elementState, data}; } isInitializationPhase = false; const {value, selection} = elementState; const [from, to] = selection; const {prefix, postfix, ...numberParts} = toNumberParts(value, params); const onlyNumber = fromNumberParts(numberParts, params); const cleanState = maskitoTransform( { selection: [ Math.max(from - prefix.length, 0), Math.max(to - prefix.length, 0), ], value: onlyNumber, }, {mask: cleanNumberMask}, ); return { elementState: { selection: selection.map((position, i) => { const deleted = onlyNumber.slice(0, Math.max(position - prefix.length, 0)) .length - cleanState.value.slice(0, cleanState.selection[i]).length; return Math.max(position - deleted, 0); }) as [number, number], value: fromNumberParts( { ...toNumberParts(cleanState.value, params), prefix: prefix && params.prefix, postfix: postfix && params.postfix, }, params, ), }, data, }; }; } ================================================ FILE: projects/kit/src/lib/masks/number/processors/leading-minus-deletion-preprocessor.ts ================================================ import type {MaskitoPreprocessor} from '@maskito/core'; import type {MaskitoNumberParams} from '../number-params'; import {fromNumberParts, toNumberParts} from '../utils'; /** * If minus sign is positioned before prefix, * any attempt to erase prefix deletes minus (without deletion of non-removable prefix) * @example -$|42 => Backspace => $|42 */ export function createLeadingMinusDeletionPreprocessor( params: Pick< Required, | 'decimalPseudoSeparators' | 'decimalSeparator' | 'maximumFractionDigits' | 'minusPseudoSigns' | 'minusSign' | 'negativePattern' | 'postfix' | 'prefix' >, ): MaskitoPreprocessor { return ({elementState}, inputType) => { const {value, selection} = elementState; const [from, to] = selection; const {prefix, minusSign, negativePattern} = params; const beginning = negativePattern === 'prefixFirst' ? prefix : `${minusSign}${prefix}`; const newValue = fromNumberParts( {...toNumberParts(value, params), minus: ''}, params, ); const diff = value.length - newValue.length; return { elementState: inputType.includes('delete') && value.includes(minusSign) && from < beginning.length ? { value: newValue, selection: [ Math.max(from - diff, beginning.length - 1), Math.max(to - diff, beginning.length - 1), ], } : elementState, }; }; } ================================================ FILE: projects/kit/src/lib/masks/number/processors/leading-zeroes-validation-postprocessor.ts ================================================ import type {MaskitoPostprocessor} from '@maskito/core'; import {escapeRegExp} from '../../../utils'; import type {MaskitoNumberParams} from '../number-params'; import {fromNumberParts, toNumberParts} from '../utils'; /** * It removes repeated leading zeroes for integer part. * @example 0,|00005 => Backspace => |5 * @example -0,|00005 => Backspace => -|5 * @example User types "000000" => 0| * @example 0| => User types "5" => 5| */ export function createLeadingZeroesValidationPostprocessor( params: Pick< Required, | 'decimalPseudoSeparators' | 'decimalSeparator' | 'maximumFractionDigits' | 'minusPseudoSigns' | 'minusSign' | 'negativePattern' | 'postfix' | 'prefix' | 'thousandSeparator' >, ): MaskitoPostprocessor { const {thousandSeparator} = params; const trimLeadingZeroes = (value: string): string => { const escapedThousandSeparator = escapeRegExp(thousandSeparator); return value .replace( // all leading zeroes followed by another zero new RegExp(String.raw`^(\D+)?[0${escapedThousandSeparator}]+(?=0)`), '$1', ) .replace( // zero followed by not-zero digit new RegExp(String.raw`^(\D+)?[0${escapedThousandSeparator}]+(?=[1-9])`), '$1', ); }; const countTrimmedZeroesBefore = (value: string, index: number): number => { const valueBefore = value.slice(0, index); const followedByZero = value.slice(index).startsWith('0'); return ( valueBefore.length - trimLeadingZeroes(valueBefore).length + (followedByZero ? 1 : 0) ); }; return ({value, selection}) => { const [from, to] = selection; const {integerPart, ...numberParts} = toNumberParts(value, params); const zeroTrimmedIntegerPart = trimLeadingZeroes(integerPart); if (integerPart === zeroTrimmedIntegerPart) { return {value, selection}; } const newFrom = from - countTrimmedZeroesBefore(value, from); const newTo = to - countTrimmedZeroesBefore(value, to); return { value: fromNumberParts( {...numberParts, integerPart: zeroTrimmedIntegerPart}, params, ), selection: [Math.max(newFrom, 0), Math.max(newTo, 0)], }; }; } ================================================ FILE: projects/kit/src/lib/masks/number/processors/min-max-postprocessor.ts ================================================ import type {MaskitoPostprocessor} from '@maskito/core'; import {CHAR_HYPHEN} from '../../../constants'; import {clamp} from '../../../utils'; import type {MaskitoNumberParams} from '../number-params'; import {maskitoParseNumber} from '../utils'; /** * This postprocessor is connected with {@link createMinMaxPlugin}: * both validate `min`/`max` bounds of entered value (but at the different point of time). */ export function createMinMaxPostprocessor( params: Required, ): MaskitoPostprocessor { const {decimalSeparator, maximumFractionDigits, min, max, minusSign} = params; return ({value, selection}) => { const parsedNumber = maskitoParseNumber(value, { ...params, bigint: !maximumFractionDigits && !value.includes(decimalSeparator), }) ?? Number.NaN; const limitedValue = /** * We cannot limit lower bound if user enters positive number. * The same for upper bound and negative number. * ___ * @example (min = 5) * Empty input => Without this condition user cannot type 42 (the first digit will be rejected) * ___ * @example (max = -10) * Value is -10 => Without this condition user cannot delete 0 to enter another digit */ parsedNumber > 0 ? clamp(parsedNumber, null, max) : clamp(parsedNumber, min); if (parsedNumber && limitedValue !== parsedNumber) { const newValue = `${limitedValue}` .replace('.', decimalSeparator) .replace(CHAR_HYPHEN, minusSign); return { value: newValue, selection: [newValue.length, newValue.length], }; } return { value, selection, }; }; } ================================================ FILE: projects/kit/src/lib/masks/number/processors/non-removable-chars-deletion-preprocessor.ts ================================================ import type {MaskitoPreprocessor} from '@maskito/core'; import type {MaskitoNumberParams} from '../number-params'; /** * Manage caret-navigation when user "deletes" non-removable digits or separators * @example 1,|42 => Backspace => 1|,42 (only if `minimumFractionDigits` is `>0`) * @example 1|,42 => Delete => 1,|42 (only if `minimumFractionDigits` is `>0`) * @example 0,|00 => Delete => 0,0|0 (only if `minimumFractionDigits` is `>0`) * @example 1 |000 => Backspace => 1| 000 (always) */ export function createNonRemovableCharsDeletionPreprocessor({ decimalSeparator, thousandSeparator, minimumFractionDigits, }: Pick< Required, 'decimalSeparator' | 'minimumFractionDigits' | 'thousandSeparator' >): MaskitoPreprocessor { return ({elementState, data}, actionType) => { const {value, selection} = elementState; const [from, to] = selection; const selectedCharacters = value.slice(from, to); const nonRemovableSeparators = minimumFractionDigits ? [decimalSeparator, thousandSeparator] : [thousandSeparator]; const areNonRemovableZeroesSelected = Boolean(minimumFractionDigits) && from > value.indexOf(decimalSeparator) && Boolean(selectedCharacters.match(/^0+$/g)); if ( (actionType !== 'deleteBackward' && actionType !== 'deleteForward') || (!nonRemovableSeparators.includes(selectedCharacters) && !areNonRemovableZeroesSelected) ) { return { elementState, data, }; } return { elementState: { value, selection: actionType === 'deleteForward' ? [to, to] : [from, from], }, data, }; }; } ================================================ FILE: projects/kit/src/lib/masks/number/processors/not-empty-integer-part-preprocessor.ts ================================================ import type {MaskitoPreprocessor} from '@maskito/core'; import {clamp, escapeRegExp} from '../../../utils'; import type {MaskitoNumberParams} from '../number-params'; import {fromNumberParts, toNumberParts} from '../utils'; /** * It pads integer part with zero if user types decimal separator (for empty input). * @example Empty input => User types "," (decimal separator) => 0,| */ export function createNotEmptyIntegerPartPreprocessor( params: Pick< Required, | 'decimalPseudoSeparators' | 'decimalSeparator' | 'maximumFractionDigits' | 'minusPseudoSigns' | 'minusSign' | 'negativePattern' | 'postfix' | 'prefix' >, ): MaskitoPreprocessor { const {maximumFractionDigits, decimalSeparator} = params; const startWithDecimalSepRegExp = new RegExp( String.raw`^\D*${escapeRegExp(decimalSeparator)}`, ); return ({elementState, data}) => { const {value, selection} = elementState; // eslint-disable-next-line @typescript-eslint/no-unused-vars const {prefix, postfix, ...numberParts} = toNumberParts(value, params); const onlyNumber = fromNumberParts(numberParts, params); const [from, to] = selection; const cleanFrom = clamp(from - prefix.length, 0, onlyNumber.length); const cleanTo = clamp(to - prefix.length, 0, onlyNumber.length); if ( maximumFractionDigits <= 0 || onlyNumber.slice(0, cleanFrom).includes(decimalSeparator) || onlyNumber.slice(cleanTo).includes(decimalSeparator) || !data.match(startWithDecimalSepRegExp) ) { return {elementState, data}; } const digitsBeforeCursor = /\d+/.exec(onlyNumber.slice(0, cleanFrom)); return { elementState, data: digitsBeforeCursor ? data : `0${data}`, }; }; } ================================================ FILE: projects/kit/src/lib/masks/number/processors/number-prefix-postprocessor.ts ================================================ import type {MaskitoPostprocessor} from '@maskito/core'; import {maskitoPrefixPostprocessorGenerator} from '../../../processors'; import type {MaskitoNumberParams} from '../number-params'; export function createNumberPrefixPostprocessor({ prefix, minusSign, negativePattern, }: Pick< Required, 'minusSign' | 'negativePattern' | 'prefix' >): MaskitoPostprocessor { return ({value, selection}, initialElementState) => maskitoPrefixPostprocessorGenerator( value.includes(minusSign) && negativePattern === 'minusFirst' ? `${minusSign}${prefix}` : prefix, )( { value: negativePattern === 'minusFirst' && value.startsWith(`${prefix}${minusSign}`) // $-100 => -$100 ? value.replace(`${prefix}${minusSign}`, `${minusSign}${prefix}`) : value, selection, }, initialElementState, ); } ================================================ FILE: projects/kit/src/lib/masks/number/processors/pseudo-character-preprocessor.ts ================================================ import type {MaskitoPreprocessor} from '@maskito/core'; import type {MaskitoNumberParams} from '../number-params'; import {fromNumberParts, toNumberParts} from '../utils'; /** * It replaces pseudo characters with valid one. * @example User types '.' (but separator is equal to comma) => dot is replaced with comma. * @example User types hyphen / en-dash / em-dash => it is replaced with minus. */ export function createPseudoCharactersPreprocessor({ validCharacter, pseudoCharacters, ...params }: Pick< Required, | 'decimalPseudoSeparators' | 'decimalSeparator' | 'maximumFractionDigits' | 'minusPseudoSigns' | 'minusSign' | 'negativePattern' | 'postfix' | 'prefix' > & { validCharacter: string; pseudoCharacters: readonly string[]; }): MaskitoPreprocessor { const pseudoCharactersRegExp = new RegExp(`[${pseudoCharacters.join('')}]`, 'gi'); return ({elementState, data}) => { const {value, selection} = elementState; const {prefix, postfix, ...numberParts} = toNumberParts(value, params); const onlyNumber = fromNumberParts(numberParts, params).replace( pseudoCharactersRegExp, validCharacter, ); return { elementState: { selection, value: fromNumberParts( { ...toNumberParts(onlyNumber, params), prefix, postfix, }, params, ), }, data: data.replace(pseudoCharactersRegExp, validCharacter), }; }; } ================================================ FILE: projects/kit/src/lib/masks/number/processors/repeated-decimal-separator-preprocessor.ts ================================================ import type {MaskitoPreprocessor} from '@maskito/core'; import {escapeRegExp, identity} from '../../../utils'; import type {MaskitoNumberParams} from '../number-params'; import {toNumberParts} from '../utils'; /** * It rejects new typed decimal separator if it already exists in text field. * Behaviour is similar to native (Chrome). * @example 1|23,45 => Press comma (decimal separator) => 1|23,45 (do nothing). */ export function createRepeatedDecimalSeparatorPreprocessor( params: Pick< Required, | 'decimalPseudoSeparators' | 'decimalSeparator' | 'maximumFractionDigits' | 'minusPseudoSigns' | 'minusSign' | 'postfix' | 'prefix' >, ): MaskitoPreprocessor { const {decimalSeparator} = params; if (!decimalSeparator) { return identity; } return ({elementState, data}) => { const {value, selection} = elementState; const [from, to] = selection; return { elementState, data: !toNumberParts(value, params).decimalSeparator || value.slice(from, to + 1).includes(decimalSeparator) ? data : data.replaceAll( new RegExp(escapeRegExp(decimalSeparator), 'gi'), '', ), }; }; } ================================================ FILE: projects/kit/src/lib/masks/number/processors/tests/leading-zeroes-validation-postprocessor.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import type {MaskitoPostprocessor} from '@maskito/core'; import type {MaskitoNumberParams} from '@maskito/kit'; import {createLeadingZeroesValidationPostprocessor} from '../leading-zeroes-validation-postprocessor'; const DEFAULT_PARAMS = { prefix: '', postfix: '', minusPseudoSigns: [], decimalPseudoSeparators: [','], negativePattern: 'prefixFirst', } as const satisfies MaskitoNumberParams; describe('createLeadingZeroesValidationPostprocessor', () => { const DUMMY_INITIAL_STATE = {value: '', selection: [0, 0]} as const; const params: Parameters[0] = { ...DEFAULT_PARAMS, decimalSeparator: ',', thousandSeparator: '', minusSign: '−', maximumFractionDigits: 0, }; let processor: MaskitoPostprocessor; const process = ( value: string, selection: [number, number], ): {selection: readonly [number, number]; value: string} => processor({value, selection}, DUMMY_INITIAL_STATE); beforeEach(() => { processor = createLeadingZeroesValidationPostprocessor(params); }); it('0|0005 => |5', () => { const {value, selection} = process('00005', [1, 1]); expect(value).toBe('5'); expect(selection).toEqual([0, 0]); }); it('−0|0005 => −|5', () => { const {value, selection} = process('−00005', [2, 2]); expect(value).toBe('−5'); expect(selection).toEqual([1, 1]); }); it('0000,4|2 => 0,4|2', () => { const {value, selection} = process('0000,42', ['0000,4'.length, '0000,4'.length]); expect(value).toBe('0,42'); expect(selection).toEqual([3, 3]); }); it('−0000,4|2 => -0.4|2', () => { const {value, selection} = process('−0000,42', [ '−0000,4'.length, '−0000,4'.length, ]); expect(value).toBe('−0,42'); expect(selection).toEqual([4, 4]); }); it('00005|,42 => 5|,42', () => { const {value, selection} = process('00005,42', ['00005'.length, '00005'.length]); expect(value).toBe('5,42'); expect(selection).toEqual([1, 1]); }); it('−0005,42| => -5,42|', () => { const {value, selection} = process('−00005,42', [ '−00005,42'.length, '−00005,42'.length, ]); expect(value).toBe('−5,42'); expect(selection).toEqual(['−5,42'.length, '−5,42'.length]); }); it('empty string => empty string', () => { const {value, selection} = process('', [0, 0]); expect(value).toBe(''); expect(selection).toEqual([0, 0]); }); it('− => -', () => { const {value, selection} = process('−', [1, 1]); expect(value).toBe('−'); expect(selection).toEqual([1, 1]); }); describe('with prefix', () => { it('$0000,4|2 => 0,4|2', () => { processor = createLeadingZeroesValidationPostprocessor({ ...params, prefix: '$', }); const {value, selection} = process('$0000,42', [ '$0000,4'.length, '$0000,4'.length, ]); expect(value).toBe('$0,42'); expect(selection).toEqual([4, 4]); }); it('$ 0000,4|2 => 0,4|2', () => { processor = createLeadingZeroesValidationPostprocessor({ ...params, prefix: '$ ', }); const {value, selection} = process('$ 0000,42', [ '$ 0000,4'.length, '$ 0000,4'.length, ]); expect(value).toBe('$ 0,42'); expect(selection).toEqual([5, 5]); }); }); }); ================================================ FILE: projects/kit/src/lib/masks/number/processors/tests/not-empty-integer-part-preprocessor.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import type {MaskitoNumberParams} from '@maskito/kit'; import {createNotEmptyIntegerPartPreprocessor} from '../not-empty-integer-part-preprocessor'; const EMPTY_ELEMENT_STATE = { value: '', selection: [0, 0] as const, }; const DEFAULT_PARAMS = { prefix: '', postfix: '', minusPseudoSigns: [], decimalPseudoSeparators: [','], negativePattern: 'prefixFirst', } as const satisfies MaskitoNumberParams; describe('createNotEmptyIntegerPartPreprocessor', () => { describe('maximumFractionDigits === 2', () => { const preprocessor = createNotEmptyIntegerPartPreprocessor({ ...DEFAULT_PARAMS, decimalSeparator: ',', maximumFractionDigits: 2, minusSign: '-', }); it('should pad integer part with zero if user inserts "a,"', () => { expect( preprocessor( { elementState: EMPTY_ELEMENT_STATE, data: 'a,', }, 'insert', ), ).toEqual({ elementState: EMPTY_ELEMENT_STATE, data: '0a,', }); }); it('should NOT pad integer part with zero if user inserts "aaa1aaa,"', () => { expect( preprocessor( { elementState: EMPTY_ELEMENT_STATE, data: 'aaa1aaa,', }, 'insert', ), ).toEqual({ elementState: EMPTY_ELEMENT_STATE, data: 'aaa1aaa,', }); }); it('should pad integer part with zero if user inserts ",3123"', () => { expect( preprocessor( { elementState: EMPTY_ELEMENT_STATE, data: ',3123', }, 'insert', ), ).toEqual({ elementState: EMPTY_ELEMENT_STATE, data: '0,3123', }); }); it('should NOT pad integer part with zero if user inserts "aaa0aaa,3123"', () => { expect( preprocessor( { elementState: EMPTY_ELEMENT_STATE, data: 'aaa0aaa,3123', }, 'insert', ), ).toEqual({ elementState: EMPTY_ELEMENT_STATE, data: 'aaa0aaa,3123', }); }); }); }); ================================================ FILE: projects/kit/src/lib/masks/number/processors/thousand-separator-postprocessor.ts ================================================ import type {MaskitoPostprocessor} from '@maskito/core'; import {identity} from '../../../utils'; import type {MaskitoNumberParams} from '../number-params'; import {fromNumberParts, toNumberParts} from '../utils'; const SPACE_REG = /\s/; const SPACE_GLOBAL_REG = /\s/g; /** * It adds symbol for separating thousands. * @example 1000000 => (thousandSeparator is equal to space) => 1 000 000. */ export function createThousandSeparatorPostprocessor( params: Pick< Required, | 'decimalPseudoSeparators' | 'decimalSeparator' | 'maximumFractionDigits' | 'minusPseudoSigns' | 'minusSign' | 'negativePattern' | 'postfix' | 'prefix' | 'thousandSeparator' | 'thousandSeparatorPattern' >, ): MaskitoPostprocessor { const {thousandSeparator, thousandSeparatorPattern} = params; if (!thousandSeparator) { return identity; } const isSeparatorWhitespace = SPACE_REG.test(thousandSeparator); const isSeparator = isSeparatorWhitespace ? (char: string): boolean => SPACE_REG.test(char) : (char: string): boolean => char === thousandSeparator; const stripSeparators = isSeparatorWhitespace ? (str: string): string => str.replaceAll(SPACE_GLOBAL_REG, '') : (str: string): string => str.replaceAll(thousandSeparator, ''); return ({value, selection}) => { const [initialFrom, initialTo] = selection; let [from, to] = selection; const {prefix, minus, integerPart, decimalSeparator, decimalPart, postfix} = toNumberParts(value, params); const rawLength = `${minus}${integerPart}${decimalSeparator ? `${decimalSeparator}${decimalPart}` : ''}` .length; const normalizedLength = fromNumberParts( {minus, integerPart, decimalSeparator, decimalPart}, params, ).length; const deletedChars = normalizedLength - rawLength; if (deletedChars > 0 && initialFrom && initialFrom <= deletedChars) { from -= deletedChars; } if (deletedChars > 0 && initialTo && initialTo <= deletedChars) { to -= deletedChars; } const integerStart = prefix.length + minus.length; const groups = thousandSeparatorPattern(stripSeparators(integerPart)); const digitAt: number[] = []; let pos = 0; for (const [i, group] of groups.entries()) { if (i > 0) { pos += thousandSeparator.length; } for (let j = 0; j < group.length; j++) { digitAt.push(pos + j); } pos += group.length; } const formatted = groups.join(thousandSeparator); const mapCursor = (cursor: number): number => { const offset = cursor - integerStart; if (offset <= 0) { return cursor; } if (offset >= integerPart.length) { return cursor + formatted.length - integerPart.length; } const digitCount = stripSeparators(integerPart.slice(0, offset)).length; const prevWasSep = isSeparator(integerPart.charAt(offset - 1)); if (prevWasSep) { return integerStart + (digitAt[digitCount] ?? formatted.length); } return integerStart + (digitAt[digitCount - 1] ?? -1) + 1; }; return { value: fromNumberParts( { prefix, minus, integerPart: formatted, decimalSeparator, decimalPart, postfix, }, params, ), selection: [mapCursor(from), mapCursor(to)], }; }; } ================================================ FILE: projects/kit/src/lib/masks/number/processors/zero-precision-preprocessor.ts ================================================ import type {MaskitoPreprocessor} from '@maskito/core'; import {escapeRegExp, identity} from '../../../utils'; import type {MaskitoNumberParams} from '../number-params'; import {fromNumberParts, toNumberParts} from '../utils'; /** * It drops decimal part if `maximumFractionDigits` is zero. * @example User pastes '123.45' (but `maximumFractionDigits` is zero) => 123 */ export function createZeroPrecisionPreprocessor( params: Pick< Required, | 'decimalPseudoSeparators' | 'decimalSeparator' | 'maximumFractionDigits' | 'minusPseudoSigns' | 'minusSign' | 'negativePattern' | 'postfix' | 'prefix' >, ): MaskitoPreprocessor { const {maximumFractionDigits, decimalSeparator} = params; if ( maximumFractionDigits > 0 || !decimalSeparator // all separators should be treated only as thousand separators ) { return identity; } const decimalPartRegExp = new RegExp(`${escapeRegExp(decimalSeparator)}.*$`, 'g'); return ({elementState, data}) => { const {value, selection} = elementState; const {prefix, postfix, ...numberParts} = toNumberParts(value, params); const [from, to] = selection; const onlyNumber = fromNumberParts(numberParts, params).replace( decimalPartRegExp, '', ); const newValue = fromNumberParts( {...toNumberParts(onlyNumber, params), prefix, postfix}, params, ); return { elementState: { selection: [ Math.min(from, newValue.length), Math.min(to, newValue.length), ], value: newValue, }, data: data.replace(decimalPartRegExp, ''), }; }; } ================================================ FILE: projects/kit/src/lib/masks/number/tests/number-mask.spec.ts ================================================ import {beforeEach, describe, expect, it} from '@jest/globals'; import { MASKITO_DEFAULT_OPTIONS, type MaskitoOptions, maskitoTransform, } from '@maskito/core'; import {type MaskitoNumberParams, maskitoParseNumber} from '@maskito/kit'; // TODO: fix later, drop implicit dependencies import {intlPattern} from '../../../../../../demo/src/pages/kit/number/examples/9-thousand-separator-pattern-intl/mask'; import { CHAR_EM_DASH, CHAR_EN_DASH, CHAR_HYPHEN, CHAR_JP_HYPHEN, CHAR_MINUS, CHAR_NO_BREAK_SPACE, CHAR_ZERO_WIDTH_SPACE, } from '../../../constants'; import {maskitoNumberOptionsGenerator} from '../number-mask'; describe('Number (maskitoTransform)', () => { describe('`maximumFractionDigits` is `0`', () => { let options: MaskitoOptions = MASKITO_DEFAULT_OPTIONS; beforeEach(() => { options = maskitoNumberOptionsGenerator({ decimalSeparator: ',', decimalPseudoSeparators: ['.'], maximumFractionDigits: 0, }); }); it('drops decimal part (123,45)', () => { expect(maskitoTransform('123,45', options)).toBe('123'); }); it('drops decimal part (123.45)', () => { expect(maskitoTransform('123.45', options)).toBe('123'); }); it('keeps minus sign (-123)', () => { expect(maskitoTransform('-123', options)).toBe('−123'); }); }); describe('`thousandSeparator` is equal to the item from `decimalPseudoSeparators`', () => { let options: MaskitoOptions = MASKITO_DEFAULT_OPTIONS; beforeEach(() => { options = maskitoNumberOptionsGenerator({ decimalSeparator: ',', decimalPseudoSeparators: ['.', ','], thousandSeparator: '.', maximumFractionDigits: 2, }); }); it('replace space to dot (121.321)', () => { expect(maskitoTransform('121 321', options)).toBe('121.321'); }); it('should`t have changes (121.321)', () => { expect(maskitoTransform('121.321', options)).toBe('121.321'); }); it('drops last symbol in decimal part (120,45)', () => { expect(maskitoTransform('120,450', options)).toBe('120,45'); }); it('keeps minus sign (-123.434) and replace space to dot', () => { expect(maskitoTransform('−120 343', options)).toBe('−120.343'); }); }); it('should accept simple and non-breaking spaces as interchangeable characters for [thousandSeparator]', () => { const options = maskitoNumberOptionsGenerator({ postfix: ' $', thousandSeparator: ' ', }); expect(maskitoTransform('45 001 $', options)).toBe('45 001 $'); // initialization phase expect(maskitoTransform('45 001 $', options)).toBe('45 001 $'); // next user interaction }); describe('`thousandSeparator` is equal to the item from `decimalPseudoSeparators` with zero padding', () => { let options: MaskitoOptions = MASKITO_DEFAULT_OPTIONS; beforeEach(() => { options = maskitoNumberOptionsGenerator({ decimalSeparator: ',', thousandSeparator: '.', decimalPseudoSeparators: ['.', ','], maximumFractionDigits: 2, minimumFractionDigits: 2, }); }); it('add dots and decimals (21.121.321,00)', () => { expect(maskitoTransform('21121321', options)).toBe('21.121.321,00'); }); }); describe('`postfix` contains point and space (` lbs.`)', () => { let options: MaskitoOptions = MASKITO_DEFAULT_OPTIONS; describe('maximumFractionDigits: 2', () => { beforeEach(() => { options = maskitoNumberOptionsGenerator({ postfix: ' lbs.', maximumFractionDigits: 2, }); }); it('empty textfield => empty textfield', () => { expect(maskitoTransform('', options)).toBe(''); }); it('only postfix => Only postfix', () => { expect(maskitoTransform(' lbs.', options)).toBe(' lbs.'); }); it('5 => 5 lbs.', () => { expect(maskitoTransform('5', options)).toBe('5 lbs.'); }); it('0.42 => 0.42 lbs.', () => { expect(maskitoTransform('0.42', options)).toBe('0.42 lbs.'); }); it('1 000 => 1 000 lbs.', () => { expect(maskitoTransform('1 000', options)).toBe('1 000 lbs.'); }); it('1 000. => 1 000. lbs.', () => { expect(maskitoTransform('1 000.', options)).toBe('1 000. lbs.'); }); it('paste 1 000 => 1 000 |lbs.', () => { expect( maskitoTransform( {value: '1 000 ', selection: ['1 000 '.length, '1 000 '.length]}, options, ), ).toEqual({ value: '1 000 lbs.', selection: ['1 000 '.length, '1 000 '.length], }); }); it('1 000 lbs. => 1 000 lbs.', () => { expect(maskitoTransform('1 000 lbs.', options)).toBe('1 000 lbs.'); }); }); describe('maximumFractionDigits: 0', () => { beforeEach(() => { options = maskitoNumberOptionsGenerator({ postfix: ' lbs.', maximumFractionDigits: 0, }); }); it('empty textfield => empty textfield', () => { expect(maskitoTransform('', options)).toBe(''); }); it('only postfix => Only postfix', () => { expect(maskitoTransform(' lbs.', options)).toBe(' lbs.'); }); it('5 => 5 lbs.', () => { expect(maskitoTransform('5', options)).toBe('5 lbs.'); }); it('0.42 => 0 lbs.', () => { expect(maskitoTransform('0.42', options)).toBe('0 lbs.'); }); it('1 000 => 1 000 lbs.', () => { expect(maskitoTransform('1 000', options)).toBe('1 000 lbs.'); }); it('1 000. => 1 000 lbs.', () => { expect(maskitoTransform('1 000.', options)).toBe('1 000 lbs.'); }); it('1 000 lbs. => 1 000 lbs.', () => { expect(maskitoTransform('1 000 lbs.', options)).toBe('1 000 lbs.'); }); }); }); describe('`prefix` contains point and space (`lbs. `)', () => { let params!: MaskitoNumberParams; let options: MaskitoOptions = MASKITO_DEFAULT_OPTIONS; describe('maximumFractionDigits: 2', () => { beforeEach(() => { options = maskitoNumberOptionsGenerator({ prefix: 'lbs. ', maximumFractionDigits: 2, }); }); it('empty textfield => empty textfield', () => { expect(maskitoTransform('', options)).toBe(''); }); it('only prefix => Only prefix', () => { expect(maskitoTransform('lbs. ', options)).toBe('lbs. '); }); it('5 => lbs. 5', () => { expect(maskitoTransform('5', options)).toBe('lbs. 5'); }); it('0.42 => lbs. 0.42', () => { expect(maskitoTransform('0.42', options)).toBe('lbs. 0.42'); }); it('1 000 => lbs. 1 000', () => { expect(maskitoTransform('1 000', options)).toBe('lbs. 1 000'); }); it('1 000. => lbs. 1 000', () => { expect(maskitoTransform('1 000.', options)).toBe('lbs. 1 000.'); }); it('lbs. 1 000 => lbs. 1 000', () => { expect(maskitoTransform('lbs. 1 000', options)).toBe('lbs. 1 000'); }); }); describe('maximumFractionDigits: 0', () => { beforeEach(() => { options = maskitoNumberOptionsGenerator({ prefix: 'lbs. ', maximumFractionDigits: 0, }); }); it('empty textfield => empty textfield', () => { expect(maskitoTransform('', options)).toBe(''); }); it('only prefix => Only prefix', () => { expect(maskitoTransform('lbs. ', options)).toBe('lbs. '); }); it('5 => lbs. 5', () => { expect(maskitoTransform('5', options)).toBe('lbs. 5'); }); it('0.42 => lbs. 0', () => { expect(maskitoTransform('0.42', options)).toBe('lbs. 0'); }); it('1 000 => lbs. 1 000', () => { expect(maskitoTransform('1 000', options)).toBe('lbs. 1 000'); }); it('1 000. => lbs. 1 000', () => { expect(maskitoTransform('1 000.', options)).toBe('lbs. 1 000'); }); it('lbs. 1 000 => lbs. 1 000', () => { expect(maskitoTransform('lbs. 1 000', options)).toBe('lbs. 1 000'); }); }); describe('prefix ends with same character as decimal separator equals (zero-width space workaround)', () => { beforeEach(() => { params = { prefix: 'lbs.', decimalSeparator: '.', maximumFractionDigits: 2, }; options = maskitoNumberOptionsGenerator(params); }); it('empty textfield => empty textfield', () => { expect(maskitoTransform('', options)).toBe(''); }); it('only prefix => prefix with zero-width space', () => { expect(maskitoTransform('lbs.', options)).toBe( `lbs.${CHAR_ZERO_WIDTH_SPACE}`, ); }); it('5 => lbs.5', () => { expect(maskitoTransform('5', options)).toBe( `lbs.${CHAR_ZERO_WIDTH_SPACE}5`, ); }); it('0.42 => lbs.0.42', () => { const expected = `lbs.${CHAR_ZERO_WIDTH_SPACE}0.42`; expect(maskitoTransform('0.42', options)).toBe(expected); expect(maskitoParseNumber(expected, params)).toBe(0.42); }); it('42 => lbs.42', () => { const expected = `lbs.${CHAR_ZERO_WIDTH_SPACE}42`; expect(maskitoTransform('42', options)).toBe(expected); expect(maskitoParseNumber(expected, params)).toBe(42); }); it('1 000 => lbs.1 000', () => { const expected = `lbs.${CHAR_ZERO_WIDTH_SPACE}1${CHAR_NO_BREAK_SPACE}000`; expect(maskitoTransform(`1${CHAR_NO_BREAK_SPACE}000`, options)).toBe( expected, ); expect(maskitoParseNumber(expected, params)).toBe(1000); }); it('1 000. => lbs.1 000.', () => { const expected = `lbs.${CHAR_ZERO_WIDTH_SPACE}1${CHAR_NO_BREAK_SPACE}000.`; expect(maskitoTransform('1 000.', options)).toBe(expected); expect(maskitoParseNumber(expected, params)).toBe(1000); }); }); }); describe('`prefix` is positioned after minus sign', () => { let options: MaskitoOptions = MASKITO_DEFAULT_OPTIONS; beforeEach(() => { options = maskitoNumberOptionsGenerator({ prefix: '$', minusSign: '-', negativePattern: 'minusFirst', decimalSeparator: '.', maximumFractionDigits: 2, }); }); it('empty textfield => empty textfield', () => { expect(maskitoTransform('', options)).toBe(''); }); it('only minus sign => add prefix too', () => { expect(maskitoTransform('-', options)).toBe('-$'); }); it('minus + prefix', () => { expect(maskitoTransform('-$', options)).toBe('-$'); }); it('-123 => -$123', () => { expect(maskitoTransform('-123', options)).toBe('-$123'); }); it('123 => $123', () => { expect(maskitoTransform('123', options)).toBe('$123'); }); it('-.42 => -$0.42', () => { expect(maskitoTransform('-.42', options)).toBe('-$.42'); }); }); describe('`postfix` starts with point | [postfix]=".000 km" & [maximumFractionDigits]="0"', () => { let options: MaskitoOptions = MASKITO_DEFAULT_OPTIONS; beforeEach(() => { options = maskitoNumberOptionsGenerator({ postfix: '.000 km', // Ensure that default point as decimal separator is compatible with postfix decimalSeparator: '.', maximumFractionDigits: 0, }); }); it('empty textfield => empty textfield', () => { expect(maskitoTransform('', options)).toBe(''); }); it('only postfix => only postfix', () => { expect(maskitoTransform('.000 km', options)).toBe('.000 km'); }); it('1.000 km => 1.000 km', () => { expect(maskitoTransform('1.000 km', options)).toBe('1.000 km'); }); it('100.000 km => 100.000 km', () => { expect(maskitoTransform('100.000 km', options)).toBe('100.000 km'); }); it('-1.000 km => -1.000 km', () => { expect(maskitoTransform(`${CHAR_HYPHEN}1.000 km`, options)).toBe( `${CHAR_MINUS}1.000 km`, ); }); it('thousandSeparator equals to empty string', () => { expect( maskitoTransform( '123456789', maskitoNumberOptionsGenerator({ postfix: '.000 km', thousandSeparator: '', }), ), ).toBe('123456789.000 km'); }); describe('thousandSeparator equals to point too', () => { beforeEach(() => { options = maskitoNumberOptionsGenerator({ postfix: '.000 km', thousandSeparator: '.', // by default, decimalSeparator === '.' & maximumFractionDigits === 0 }); }); it('-123 => -123.000 km', () => { expect(maskitoTransform('-123', options)).toBe(`${CHAR_MINUS}123.000 km`); }); it('123456 => 123.456.000 km', () => { expect(maskitoTransform('123456', options)).toBe('123.456.000 km'); }); }); }); describe('should transform full width number to half width', () => { describe('at any time', () => { it('at the 1st time (after initialization)', () => { const options = maskitoNumberOptionsGenerator({thousandSeparator: '_'}); expect(maskitoTransform('12345', options)).toBe('12_345'); }); it('at the 2nd time (after initialization)', () => { const options = maskitoNumberOptionsGenerator({thousandSeparator: '_'}); maskitoTransform('12345', options); expect(maskitoTransform('12345', options)).toBe('12_345'); }); }); }); describe('applies `minusSign` property correctly', () => { const minuses = [ {value: CHAR_HYPHEN, name: 'hyphen'}, {value: CHAR_MINUS, name: 'unicode minus sign'}, {value: 'i', name: 'i'}, ]; const numbers = ['23', '321', '2 432']; const pseudoMinuses = [ {value: CHAR_HYPHEN, name: 'hyphen'}, {value: CHAR_EN_DASH, name: 'en-dash'}, {value: CHAR_EM_DASH, name: 'em-dash'}, {value: CHAR_JP_HYPHEN, name: 'japanese prolonged sound mark'}, {value: CHAR_MINUS, name: 'unicode minus sign'}, ]; minuses.forEach((minus) => { const options = maskitoNumberOptionsGenerator({ minusSign: minus.value, thousandSeparator: ' ', }); pseudoMinuses.forEach((pseudoMinus) => { numbers.forEach((number) => { it(`transforms ${pseudoMinus.name} into ${minus.name}`, () => { expect( maskitoTransform(`${pseudoMinus.value}${number}`, options), ).toBe(`${minus.value}${number}`); }); }); }); }); }); describe('custom minus should properly work with min(max) value', () => { let options = MASKITO_DEFAULT_OPTIONS; [ {value: CHAR_HYPHEN, name: 'hyphen'}, {value: CHAR_EN_DASH, name: 'en-dash'}, {value: CHAR_EM_DASH, name: 'em-dash'}, {value: CHAR_JP_HYPHEN, name: 'japanese prolonged sound mark'}, {value: CHAR_MINUS, name: 'unicode minus sign'}, ].forEach((minus) => { describe(`applies ${minus.name} properly`, () => { beforeEach(() => { options = maskitoNumberOptionsGenerator({ min: -123, minusSign: minus.value, }); }); it(`-94 => ${minus.value}94`, () => { expect(maskitoTransform(`${minus.value}94`, options)).toBe( `${minus.value}94`, ); }); it(`-432 => ${minus.value}123`, () => { expect(maskitoTransform(`${minus.value}432`, options)).toBe( `${minus.value}123`, ); }); }); }); }); describe('autofill value with extra leading and trailing whitespace (thousand separator is equal to whitespace too)', () => { it('123456', () => { const options = maskitoNumberOptionsGenerator({thousandSeparator: ' '}); expect(maskitoTransform(' 123456 ', options)).toBe('123 456'); }); it('|123 => |123', () => { const options = maskitoNumberOptionsGenerator({thousandSeparator: ' '}); expect(maskitoTransform({value: ' 123', selection: [1, 1]}, options)).toEqual( { value: '123', selection: [0, 0], }, ); // Check when initial calibration processor already worked expect(maskitoTransform({value: ' 123', selection: [1, 1]}, options)).toEqual( { value: '123', selection: [0, 0], }, ); }); }); describe('thousandSeparatorPattern', () => { it('default behavior unchanged: 123456789 => 123 456 789 (non-breaking space)', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: CHAR_NO_BREAK_SPACE, }); expect(maskitoTransform('123456789', options)).toBe( `123${CHAR_NO_BREAK_SPACE}456${CHAR_NO_BREAK_SPACE}789`, ); }); it('small number unaffected: 999 => 999', () => { const options = maskitoNumberOptionsGenerator({thousandSeparator: ','}); expect(maskitoTransform('999', options)).toBe('999'); }); it('zero unaffected: 0 => 0', () => { const options = maskitoNumberOptionsGenerator({thousandSeparator: ','}); expect(maskitoTransform('0', options)).toBe('0'); }); describe('Indian numbering system pattern', () => { const indianPattern = (digits: string): readonly string[] => { if (!digits) { return []; } const last3 = digits.slice(-3); const rest = digits.slice(0, -3); const groups: string[] = []; for (let i = 0; i < rest.length; i += 2) { groups.push(rest.slice(i, i + 2)); } return [...groups, last3].filter(Boolean); }; it('123456789 => 12,34,56,789', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: indianPattern, }); expect(maskitoTransform('123456789', options)).toBe('12,34,56,789'); }); it('single group: 1200 => 1,200', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: indianPattern, }); expect(maskitoTransform('1200', options)).toBe('1,200'); }); it('negative number: -123456789 => -12,34,56,789', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: indianPattern, }); expect(maskitoTransform('-123456789', options)).toBe( `${CHAR_MINUS}12,34,56,789`, ); }); it('decimal part is unaffected: 1234567.89 => 12,34,567.89', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', decimalSeparator: '.', maximumFractionDigits: 2, thousandSeparatorPattern: indianPattern, }); expect(maskitoTransform('1234567.89', options)).toBe('12,34,567.89'); }); it('idempotent: re-transform 12,34,56,789 => 12,34,56,789', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: indianPattern, }); expect(maskitoTransform('12,34,56,789', options)).toBe('12,34,56,789'); }); }); describe('custom thousandSeparator "_"', () => { it('1234567 => 1_234_567', () => { const options = maskitoNumberOptionsGenerator({thousandSeparator: '_'}); expect(maskitoTransform('1234567', options)).toBe('1_234_567'); }); it('negative number: -1234567 => -1_234_567', () => { const options = maskitoNumberOptionsGenerator({thousandSeparator: '_'}); expect(maskitoTransform('-1234567', options)).toBe( `${CHAR_MINUS}1_234_567`, ); }); it('decimal part is unaffected: 1234567.89 => 1_234_567.89', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: '_', decimalSeparator: '.', maximumFractionDigits: 2, }); expect(maskitoTransform('1234567.89', options)).toBe('1_234_567.89'); }); it('idempotent: re-transform 1_234_567 => 1_234_567', () => { const options = maskitoNumberOptionsGenerator({thousandSeparator: '_'}); expect(maskitoTransform('1_234_567', options)).toBe('1_234_567'); }); it('with Indian 2+3 pattern: 1234567 => 12_34_567', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: '_', thousandSeparatorPattern: intlPattern('en-IN'), }); expect(maskitoTransform('1234567', options)).toBe('12_34_567'); }); }); it('no-op pattern (single group): 1234567 => 1234567', () => { const noGroupingPattern = (digits: string): readonly string[] => digits ? [digits] : []; const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: noGroupingPattern, }); expect(maskitoTransform('1234567', options)).toBe('1234567'); }); it('prefix + custom pattern: ₹1234567 => ₹12,34,567', () => { const options = maskitoNumberOptionsGenerator({ prefix: '₹', thousandSeparator: ',', thousandSeparatorPattern: intlPattern('en-IN'), }); expect(maskitoTransform('1234567', options)).toBe('₹12,34,567'); }); it('postfix + custom pattern: 1234567 => 1.234.567 €', () => { const options = maskitoNumberOptionsGenerator({ postfix: ' €', thousandSeparator: '.', decimalSeparator: ',', thousandSeparatorPattern: intlPattern('de-DE'), }); expect(maskitoTransform('1234567', options)).toBe('1.234.567 €'); }); describe('Intl.NumberFormat-based grouping', () => { it('de-DE: 1234567 => 1.234.567 (3-digit grouping, dot separator)', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: '.', decimalSeparator: ',', thousandSeparatorPattern: intlPattern('de-DE'), }); expect(maskitoTransform('1234567', options)).toBe('1.234.567'); }); it('ja-JP: 1234567 => 1,234,567 (3-digit grouping)', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: intlPattern('ja-JP'), }); expect(maskitoTransform('1234567', options)).toBe('1,234,567'); }); it('en-IN: 123456789 => 12,34,56,789 (Indian 2+3 grouping)', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: intlPattern('en-IN'), }); expect(maskitoTransform('123456789', options)).toBe('12,34,56,789'); }); it('en-IN: single group 1200 => 1,200', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: intlPattern('en-IN'), }); expect(maskitoTransform('1200', options)).toBe('1,200'); }); it('en-US: same as default 3-digit grouping', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: intlPattern('en-US'), }); expect(maskitoTransform('123456789', options)).toBe('123,456,789'); }); it('en-US: large number 1234567890 => 1,234,567,890', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: intlPattern('en-US'), }); expect(maskitoTransform('1234567890', options)).toBe('1,234,567,890'); }); it('en-IN: negative number -123456789 => -12,34,56,789', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: intlPattern('en-IN'), }); expect(maskitoTransform('-123456789', options)).toBe( `${CHAR_MINUS}12,34,56,789`, ); }); it('de-DE: decimal part unaffected: 1234567,89 => 1.234.567,89', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: '.', decimalSeparator: ',', maximumFractionDigits: 2, thousandSeparatorPattern: intlPattern('de-DE'), }); expect(maskitoTransform('1234567,89', options)).toBe('1.234.567,89'); }); it('de-DE: minimumFractionDigits: 1234 => 1.234,00', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: '.', decimalSeparator: ',', maximumFractionDigits: 2, minimumFractionDigits: 2, thousandSeparatorPattern: intlPattern('de-DE'), }); expect(maskitoTransform('1234', options)).toBe('1.234,00'); }); it('hi-IN: 1234567 => 12,34,567 (Hindi India, Indian grouping)', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: intlPattern('hi-IN'), }); expect(maskitoTransform('1234567', options)).toBe('12,34,567'); }); it('hi-IN: 123456789 => 12,34,56,789 (scales same as en-IN)', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: intlPattern('hi-IN'), }); expect(maskitoTransform('123456789', options)).toBe('12,34,56,789'); }); it('fr-FR: 1234567 => 1,234,567 (French, 3-digit grouping)', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: intlPattern('fr-FR'), }); expect(maskitoTransform('1234567', options)).toBe('1,234,567'); }); it('ru-RU: 1234567 => 1,234,567 (Russian, 3-digit grouping)', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: intlPattern('ru-RU'), }); expect(maskitoTransform('1234567', options)).toBe('1,234,567'); }); it('pt-BR: 1234567 => 1,234,567 (Brazilian Portuguese, 3-digit grouping)', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: intlPattern('pt-BR'), }); expect(maskitoTransform('1234567', options)).toBe('1,234,567'); }); it('zh-CN: 1234567 => 1,234,567 (Chinese Simplified, 3-digit grouping)', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: intlPattern('zh-CN'), }); expect(maskitoTransform('1234567', options)).toBe('1,234,567'); }); it('ko-KR: 1234567 => 1,234,567 (Korean, 3-digit grouping)', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: intlPattern('ko-KR'), }); expect(maskitoTransform('1234567', options)).toBe('1,234,567'); }); it('tr-TR: 1234567 => 1,234,567 (Turkish, 3-digit grouping)', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: intlPattern('tr-TR'), }); expect(maskitoTransform('1234567', options)).toBe('1,234,567'); }); describe('Indian 2+3 grouping', () => { it('ne-NP: uses Indian 2+3 grouping', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: intlPattern('ne-NP'), }); expect(maskitoTransform('1234567', options)).toBe('12,34,567'); }); it('en-IN: 1200 => 1,200 (Indian grouping, 4-digit number)', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: intlPattern('en-IN'), }); expect(maskitoTransform('1200', options)).toBe('1,200'); }); }); it('es-ES: 1200 => 1200 (no separator for 4-digit numbers in Spanish locale)', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: intlPattern('es-ES'), }); expect(maskitoTransform('1200', options)).toBe('1200'); }); }); }); describe('BigInt support', () => { it('number beyond MAX_SAFE_INTEGER is formatted correctly', () => { const options = maskitoNumberOptionsGenerator({thousandSeparator: ','}); expect(maskitoTransform('9007199254740993', options)).toBe( '9,007,199,254,740,993', ); }); it('negative number beyond MIN_SAFE_INTEGER is formatted correctly', () => { const options = maskitoNumberOptionsGenerator({thousandSeparator: ','}); expect(maskitoTransform('-9007199254740993', options)).toBe( `${CHAR_MINUS}9,007,199,254,740,993`, ); }); it('max: BigInt clamps value exceeding max', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', max: BigInt('1000000'), }); expect(maskitoTransform('9007199254740993', options)).toBe('1,000,000'); }); it('min: BigInt clamps negative value below min', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', min: BigInt('-1000000'), }); expect(maskitoTransform('-9007199254740993', options)).toBe( `${CHAR_MINUS}1,000,000`, ); }); it('thousandSeparatorPattern + BigInt: large Indian-grouped number', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ',', thousandSeparatorPattern: intlPattern('en-IN'), }); expect(maskitoTransform('12345678901', options)).toBe('12,34,56,78,901'); }); }); describe('min/max validation should ignore digits inside affixes', () => { describe('postfix = cm3', () => { it('value within max is NOT clamped', () => { expect( maskitoTransform( '123', maskitoNumberOptionsGenerator({max: 123, postfix: 'cm3'}), ), ).toBe('123cm3'); expect( maskitoTransform( '1234567890123456', maskitoNumberOptionsGenerator({ postfix: 'cm3', max: Number.MAX_SAFE_INTEGER, }), ), ).toBe('1 234 567 890 123 456cm3'); }); it('value exceeding max IS clamped without losing postfix', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: ' ', max: 999, postfix: 'cm3', }); expect(maskitoTransform('12345', options)).toBe('999cm3'); }); it('negative value within min is NOT clamped', () => { expect( maskitoTransform( '-1234567890123456', maskitoNumberOptionsGenerator({postfix: 'cm3'}), ), ).toBe(`${CHAR_MINUS}1 234 567 890 123 456cm3`); }); }); describe('prefix = 100 x', () => { it('value within max is NOT clamped', () => { expect( maskitoTransform( '500', maskitoNumberOptionsGenerator({ max: 999, prefix: '100 x ', }), ), ).toBe('100 x 500'); }); it('value exceeding max IS clamped', () => { expect( maskitoTransform( '1234', maskitoNumberOptionsGenerator({ max: 999, prefix: '100 x ', }), ), ).toBe('100 x 999'); }); }); }); it('[thousandSeparator] is equal to [decimalSeparator] when [maximumFractionDigits]=0', () => { const options = maskitoNumberOptionsGenerator({ thousandSeparator: '.', decimalSeparator: '.', // default value maximumFractionDigits: 0, // default value }); expect(maskitoTransform('123.456', options)).toBe('123.456'); }); }); ================================================ FILE: projects/kit/src/lib/masks/number/utils/extract-affixes.ts ================================================ import {escapeRegExp} from '../../../utils/escape-reg-exp'; import type {MaskitoNumberParams} from '../number-params'; export function extractAffixes( value: string, { prefix, postfix, decimalSeparator, decimalPseudoSeparators, minusSign, minusPseudoSigns, maximumFractionDigits, }: Pick< Required, | 'decimalPseudoSeparators' | 'decimalSeparator' | 'maximumFractionDigits' | 'minusPseudoSigns' | 'minusSign' | 'postfix' | 'prefix' >, ): { extractedPrefix: string; extractedPostfix: string; cleanValue: string; } { const decimalSeparators = [...decimalPseudoSeparators, decimalSeparator] .map((x) => `\\${x}`) .join(''); const minuses = [...minusPseudoSigns, minusSign].map((x) => `\\${x}`).join(''); const prefixRegExp = prefix && new RegExp(`^([${minuses}])?(${prefix.split('').map(escapeRegExp).join('?')}?)`); const postfixRegExp = postfix && new RegExp(`${postfix.split('').map(escapeRegExp).join('?')}?$`); const [, , extractedPrefix = ''] = value.match(prefixRegExp) ?? []; const [extractedPostfix = ''] = value.match(postfixRegExp) ?? []; const cleanValue = value .replace(prefixRegExp, prefix && '$1') .replace(postfixRegExp, ''); const leadingDecimalSeparatorRE = new RegExp( decimalSeparator && maximumFractionDigits > 0 ? `^[${decimalSeparators}]` : '', ); const leadingDigitsRE = new RegExp(value.endsWith(postfix) ? '' : String.raw`^\d+`); const trailingDecimalSeparatorRE = new RegExp( decimalSeparator && maximumFractionDigits > 0 ? `[${decimalSeparators}]$` : '', ); const trailingDigitsRE = new RegExp(value.startsWith(prefix) ? '' : String.raw`\d+$`); return { extractedPrefix: extractedPrefix .replace(trailingDecimalSeparatorRE, '') .replace(trailingDigitsRE, ''), extractedPostfix: extractedPostfix .replace(leadingDecimalSeparatorRE, '') .replace(leadingDigitsRE, ''), cleanValue: `${trailingDigitsRE.exec(extractedPrefix)?.[0] ?? ''}${trailingDecimalSeparatorRE.exec(extractedPrefix)?.[0] ?? ''}${cleanValue}${leadingDigitsRE.exec(extractedPostfix)?.[0] ?? ''}${leadingDecimalSeparatorRE.exec(extractedPostfix)?.[0] ?? ''}`, }; } ================================================ FILE: projects/kit/src/lib/masks/number/utils/generate-mask-expression.ts ================================================ import type {MaskitoMask} from '@maskito/core'; import {escapeRegExp} from '../../../utils'; import type {MaskitoNumberParams} from '../number-params'; export function generateMaskExpression({ decimalPseudoSeparators, decimalSeparator, maximumFractionDigits, min, minusSign, minusPseudoSigns, postfix, prefix, thousandSeparator, }: Pick< Required, | 'decimalPseudoSeparators' | 'decimalSeparator' | 'maximumFractionDigits' | 'min' | 'minusPseudoSigns' | 'minusSign' | 'postfix' | 'prefix' | 'thousandSeparator' >): MaskitoMask { const computedPrefix = min < 0 && [minusSign, ...minusPseudoSigns].includes(prefix) ? '' : computeAllOptionalCharsRegExp(prefix); const digit = String.raw`\d`; const optionalMinus = min < 0 ? `[${minusSign}${minusPseudoSigns.map((x) => `\\${x}`).join('')}]?` : ''; const integerPart = thousandSeparator ? `[${digit}${escapeRegExp(thousandSeparator).replaceAll(/\s/g, String.raw`\s`)}]*` : `[${digit}]*`; const precisionPart = Number.isFinite(maximumFractionDigits) ? maximumFractionDigits : ''; const decimalPart = maximumFractionDigits > 0 ? `([${escapeRegExp(decimalSeparator)}${decimalPseudoSeparators .map(escapeRegExp) .join('')}]${digit}{0,${precisionPart}})?` : ''; const computedPostfix = computeAllOptionalCharsRegExp(postfix); const beginning = `(${optionalMinus}${computedPrefix}|${computedPrefix}${optionalMinus})`; return new RegExp(`^${beginning}${integerPart}${decimalPart}${computedPostfix}$`); } function computeAllOptionalCharsRegExp(str: string): string { return str ? str .split('') .map((char) => `${escapeRegExp(char)}?`) .join('') : ''; } ================================================ FILE: projects/kit/src/lib/masks/number/utils/index.ts ================================================ export * from './extract-affixes'; export * from './generate-mask-expression'; export * from './number-parts'; export * from './parse-number'; export * from './stringify-number-without-exp'; export * from './validate-decimal-pseudo-separators'; export * from './with-number-defaults'; ================================================ FILE: projects/kit/src/lib/masks/number/utils/number-parts.ts ================================================ import {escapeRegExp} from '../../../utils'; import type {MaskitoNumberParams} from '../number-params'; import {extractAffixes} from './extract-affixes'; interface NumberParts { prefix: string; minus: string; integerPart: string; decimalPart: string; decimalSeparator: string; postfix: string; } export function toNumberParts( value: string, params: Pick< Required, | 'decimalPseudoSeparators' | 'decimalSeparator' | 'maximumFractionDigits' | 'minusPseudoSigns' | 'minusSign' | 'postfix' | 'prefix' >, ): NumberParts { const {extractedPrefix, cleanValue, extractedPostfix} = extractAffixes(value, params); const { decimalSeparator, minusSign, minusPseudoSigns, decimalPseudoSeparators, maximumFractionDigits, } = params; const [integerWithMinus = '', decimalPart = ''] = decimalSeparator ? cleanValue.split(decimalSeparator) : [cleanValue]; const minuses = [minusSign, ...minusPseudoSigns].map((x) => `\\${x}`).join(''); const [, minus = '', integerPart = ''] = new RegExp(`^([${minuses}])?(.*)`).exec(integerWithMinus) || []; return { prefix: extractedPrefix, minus, integerPart, decimalPart, decimalSeparator: decimalSeparator && maximumFractionDigits > 0 ? (new RegExp( `[${[decimalSeparator, ...decimalPseudoSeparators].map(escapeRegExp).join('')}]`, 'i', ).exec(cleanValue)?.[0] ?? '') : '', postfix: extractedPostfix, }; } export function fromNumberParts( { minus = '', integerPart = '', decimalPart = '', prefix = '', postfix = '', decimalSeparator = '', }: Partial, params: Pick< Required, 'decimalSeparator' | 'minusSign' | 'negativePattern' | 'prefix' >, ): string { const separator = decimalPart ? params.decimalSeparator : decimalSeparator; const beginning = params.negativePattern === 'minusFirst' ? `${minus}${prefix}` : `${prefix}${minus}`; return `${beginning}${integerPart}${separator}${decimalPart}${postfix}`; } ================================================ FILE: projects/kit/src/lib/masks/number/utils/parse-number.ts ================================================ import {CHAR_HYPHEN} from '../../../constants'; import {type MaskitoNumberParams} from '../number-params'; import {fromNumberParts, toNumberParts} from './number-parts'; import {withNumberDefaults} from './with-number-defaults'; export function maskitoParseNumber( maskedNumber: string, params?: MaskitoNumberParams & {bigint?: false}, ): number; export function maskitoParseNumber( maskedNumber: string, params?: MaskitoNumberParams & {bigint: true}, ): bigint | null; export function maskitoParseNumber( maskedNumber: string, params?: MaskitoNumberParams & {bigint: boolean}, ): bigint | number | null; export function maskitoParseNumber( maskedNumber: string, {bigint = false, ...optionalParams}: MaskitoNumberParams & {bigint?: boolean} = {}, ): bigint | number | null { const params = withNumberDefaults(optionalParams); const {minus, integerPart, decimalSeparator, ...numberParts} = toNumberParts( maskedNumber, params, ); const unmaskedNumber = fromNumberParts( { ...numberParts, integerPart: integerPart.replaceAll(/\D/g, ''), decimalSeparator: decimalSeparator && '.', prefix: '', postfix: '', minus: '', }, {...params, decimalSeparator: '.'}, ); if (unmaskedNumber) { const sign = minus ? CHAR_HYPHEN : ''; return bigint ? BigInt(`${sign}${unmaskedNumber}`) : Number(`${sign}${unmaskedNumber}`); } return bigint ? null : Number.NaN; } ================================================ FILE: projects/kit/src/lib/masks/number/utils/stringify-number-without-exp.ts ================================================ import {CHAR_HYPHEN} from '../../../constants'; import {toNumberParts} from './number-parts'; const LOCALE: Intl.Locale[] = []; const DEFAULT = { minusSign: CHAR_HYPHEN, minusPseudoSigns: [], prefix: '', postfix: '', decimalSeparator: '.', decimalPseudoSeparators: [], maximumFractionDigits: Infinity, }; /** * Converts a number to a decimal string without using exponential notation. * * - Numbers without exponent are returned as-is. * - Numbers with a positive exponent (`e+N`) are expanded using locale formatting. * - Numbers with a negative exponent (`e-N`) are expanded manually to avoid * precision limits of `Number#toFixed` and locale rounding. * * The sign of the number and the sign of the exponent are handled independently. * This guarantees correct formatting for cases like `-1.23e-8`. * * @param value Number or bigint to convert. * @returns Full decimal string representation without exponent notation. * * @example * stringifyNumberWithoutExp(1e25) * // "10000000000000000000000000" * * @example * stringifyNumberWithoutExp(1.23e-8) * // "0.0000000123" * * @example * stringifyNumberWithoutExp(-1.23e-8) * // "-0.0000000123" */ export function stringifyNumberWithoutExp(value: bigint | number): string { const valueAsString = String(value); const [numberPart = '', exponent] = valueAsString.split('e'); if (!exponent) { return valueAsString; } if (!exponent.startsWith(CHAR_HYPHEN)) { return value.toLocaleString(LOCALE, {useGrouping: false}); } const {minus, integerPart, decimalPart} = toNumberParts(numberPart, DEFAULT); const digits = `${integerPart}${decimalPart}`; const shift = Math.abs(Number(exponent)); const totalZeros = shift - integerPart.length; let result: string; if (totalZeros >= 0) { result = `0.${'0'.repeat(totalZeros)}${digits}`; } else { const index = integerPart.length - shift; result = `${digits.slice(0, index)}.${digits.slice(index)}`; } return `${minus}${result}`; } ================================================ FILE: projects/kit/src/lib/masks/number/utils/stringify-number.ts ================================================ import {maskitoTransform} from '@maskito/core'; import {clamp} from '../../../utils'; import {maskitoNumberOptionsGenerator} from '../number-mask'; import type {MaskitoNumberParams} from '../number-params'; import {stringifyNumberWithoutExp} from './stringify-number-without-exp'; import {withNumberDefaults} from './with-number-defaults'; export function maskitoStringifyNumber( number: bigint | number | null, optionalParams: MaskitoNumberParams, ): string { if (Number.isNaN(number) || number === null) { return ''; } const params = withNumberDefaults(optionalParams); const value = stringifyNumberWithoutExp( clamp(number, params.min, params.max), ).replace('.', params.decimalSeparator); return maskitoTransform(value, maskitoNumberOptionsGenerator(params)); } ================================================ FILE: projects/kit/src/lib/masks/number/utils/tests/parse-number.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import { CHAR_EM_DASH, CHAR_EN_DASH, CHAR_HYPHEN, CHAR_JP_HYPHEN, CHAR_MINUS, } from '../../../../constants'; import type {MaskitoNumberParams} from '../../number-params'; import {maskitoParseNumber} from '../parse-number'; describe('maskitoParseNumber', () => { describe('decimal separator is dot (default one)', () => { it('thousand separator is space', () => { expect(maskitoParseNumber('1 000 000.42')).toBe(1000000.42); }); it('thousand separator is hyphen', () => { expect(maskitoParseNumber('1-000-000.42')).toBe(1000000.42); }); it('thousand separator is empty string', () => { expect(maskitoParseNumber('1000000.42')).toBe(1000000.42); }); it('thousand separator is point & maximumFractionDigits is unset (default `0`) & decimal separator is unset (default point)', () => { expect(maskitoParseNumber('123.456.789', {thousandSeparator: '.'})).toBe( 123456789, ); }); it('empty decimal part & thousand separator is comma', () => { expect(maskitoParseNumber('1,000,000')).toBe(1000000); }); it('trailing decimal separator', () => { expect(maskitoParseNumber('0.')).toBe(0); }); }); describe('decimal separator is comma', () => { it('thousand separator is space', () => { expect(maskitoParseNumber('42 111,42', {decimalSeparator: ','})).toBe( 42111.42, ); }); it('thousand separator is hyphen', () => { expect(maskitoParseNumber('42-111,42', {decimalSeparator: ','})).toBe( 42111.42, ); }); it('thousand separator is empty string', () => { expect(maskitoParseNumber('42111,42', {decimalSeparator: ','})).toBe( 42111.42, ); }); it('empty decimal part & thousand separator is dot', () => { expect(maskitoParseNumber('42.111', {decimalSeparator: ','})).toBe(42111); }); it('trailing decimal separator', () => { expect(maskitoParseNumber('42,', {decimalSeparator: ','})).toBe(42); }); }); describe('decimal separator is empty string', () => { it('thousand separator is point', () => { expect(maskitoParseNumber('123.456.789', {decimalSeparator: ''})).toBe( 123456789, ); }); it('thousand separator is empty string', () => { expect(maskitoParseNumber('123456', {decimalSeparator: ''})).toBe(123456); }); }); describe('negative numbers', () => { describe('minus sign', () => { it('can be minus', () => { expect(maskitoParseNumber(`${CHAR_MINUS}1`)).toBe(-1); }); it('can be hyphen', () => { expect(maskitoParseNumber(`${CHAR_HYPHEN}123 456`)).toBe(-123456); }); it('can be en-dash', () => { expect(maskitoParseNumber(`${CHAR_EN_DASH}123 456 789`)).toBe(-123456789); }); it('can be em-dash', () => { expect(maskitoParseNumber(`${CHAR_EM_DASH}42`)).toBe(-42); }); it('can be katakana-hiragana prolonged sound mark', () => { expect(maskitoParseNumber(`${CHAR_JP_HYPHEN}42`)).toBe(-42); }); it('can be any custom character', () => { expect(maskitoParseNumber('x42', {minusSign: 'x'})).toBe(-42); expect(maskitoParseNumber('!42', {minusSign: '!'})).toBe(-42); }); }); it('parses negative integer number when thousand separator is hyphen & minus sign is hyphen', () => { expect(maskitoParseNumber('-123-456')).toBe(-123456); }); it('parses negative number with decimal part', () => { expect(maskitoParseNumber('-123.456')).toBe(-123.456); }); }); describe('Prefix & Postfix', () => { it('parses number with only prefix', () => { expect(maskitoParseNumber('$42')).toBe(42); }); it('parses number with only postfix', () => { expect(maskitoParseNumber('42%')).toBe(42); }); it('parses number with both prefix and postfix', () => { expect(maskitoParseNumber('$42 per day')).toBe(42); }); it('parses negative number with prefix', () => { expect(maskitoParseNumber('>-42', {prefix: '>'})).toBe(-42); expect(maskitoParseNumber('> -42', {prefix: '> '})).toBe(-42); }); describe('prefix/postfix includes point and space', () => { const postfix = ' lbs.'; const prefix = 'lbs. '; it('parses INTEGER number with postfix " lbs."', () => { expect(maskitoParseNumber('42 lbs.', {postfix})).toBe(42); expect(maskitoParseNumber('1 000 lbs.', {postfix})).toBe(1000); expect(maskitoParseNumber('1 000 lbs.', {postfix})).toBe(1000); }); it('parses DECIMAL number with postfix " lbs."', () => { expect(maskitoParseNumber('0.42 lbs.', {postfix})).toBe(0.42); expect(maskitoParseNumber('.42 lbs.', {postfix})).toBe(0.42); expect(maskitoParseNumber('1 000.42 lbs.', {postfix})).toBe(1000.42); expect(maskitoParseNumber('1 000. lbs.', {postfix})).toBe(1000); }); it('parses INTEGER number with prefix "lbs. "', () => { expect(maskitoParseNumber('lbs. 42', {prefix})).toBe(42); expect(maskitoParseNumber('lbs. 1 000', {prefix})).toBe(1000); expect(maskitoParseNumber('lbs. 1 000', {prefix})).toBe(1000); }); it('parses DECIMAL number with prefix "lbs. "', () => { expect(maskitoParseNumber('lbs. 0.42', {prefix})).toBe(0.42); expect(maskitoParseNumber('lbs. .42', {prefix})).toBe(0.42); expect(maskitoParseNumber('lbs. 1 000.42', {prefix})).toBe(1000.42); expect(maskitoParseNumber('lbs. 1 000.42', {prefix})).toBe(1000.42); const zeroWidthSpace = '\u200B'; expect( maskitoParseNumber(`lbs.${zeroWidthSpace}1 000.42`, {prefix}), ).toBe(1000.42); }); }); describe('postfix includes digits, cm3', () => { const postfix = 'cm3'; it('no value, only postfix', () => { expect(maskitoParseNumber(postfix, {postfix})).toBeNaN(); }); it('0cm3 => 0', () => { expect(maskitoParseNumber(`0${postfix}`, {postfix})).toBe(0); }); it('3cm3 => 3', () => { expect(maskitoParseNumber(`3${postfix}`, {postfix})).toBe(3); }); it('123cm3 => 123', () => { expect(maskitoParseNumber(`123${postfix}`, {postfix})).toBe(123); }); }); }); describe('BigInt mode', () => { it('parses large positive integer as BigInt', () => { expect( maskitoParseNumber('987.654.321.098.765.432.100', { thousandSeparator: '.', bigint: true, }), ).toEqual(BigInt('987654321098765432100')); }); it('parses large negative integer as BigInt', () => { expect( maskitoParseNumber('-123 456 789 012 345 678 900', { thousandSeparator: ' ', bigint: Boolean('ALWAYS true, but boolean for TS'), }), ).toBe(-123456789012345678900n); }); it('throws native error on attempt to use {bigint: true} with decimal number', () => { expect(() => maskitoParseNumber('1_234.56', { thousandSeparator: '_', bigint: true, }), ).toThrow('Cannot convert 1234.56 to a BigInt'); }); describe('TypeScript overloads', () => { it('returns `number` when `bigint` is unset', () => { const result = maskitoParseNumber('42'); expect(typeof result === 'number').toBe(true); }); describe('returns `number` type when `bigint` is explicitly set to `false`', () => { it('and actual returned value is `number` for non-empty value', () => { const result = maskitoParseNumber('42', {bigint: false}); expect(typeof result === 'number').toBe(true); }); it('and actual returned value is `NaN` for empty value', () => { const result = maskitoParseNumber('-', {bigint: false}); expect(Number.isNaN(result)).toBe(true); }); }); describe('returns `bigint | null` type when `bigint` is explicitly set to `true`', () => { it('and actual returned value is `bigint` for non-empty value', () => { const result = maskitoParseNumber('42', {bigint: true}); expect(typeof result === 'bigint').toBe(true); }); it('and actual returned value is `null` for empty value', () => { const result = maskitoParseNumber('', {bigint: true}); expect(result === null).toBe(true); }); }); it('returns `bigint | number | null` when `bigint` is computed dynamically', () => { const result = maskitoParseNumber('42', {bigint: Boolean(1)}); const TRUE = true; expect(typeof result === 'bigint').toBe(TRUE); }); }); }); describe('Minus is positioned before prefix', () => { const params: MaskitoNumberParams = { decimalSeparator: '.', minusSign: CHAR_MINUS, minusPseudoSigns: [ CHAR_MINUS, CHAR_HYPHEN, CHAR_EN_DASH, CHAR_EM_DASH, CHAR_JP_HYPHEN, ], // Even without knowing `prefix` / `maximumFractionDigits` values `maskitoParseNumber` is capable to parse number }; it('-$42 (with leading minus character)', () => { expect(maskitoParseNumber(`${CHAR_MINUS}$42`, params)).toBe(-42); }); describe('pseudo minuses', () => { [CHAR_MINUS, CHAR_HYPHEN, CHAR_EN_DASH, CHAR_EM_DASH, CHAR_JP_HYPHEN].forEach( (pseudoMinus) => { it(`${pseudoMinus}$42`, () => { expect(maskitoParseNumber(`${pseudoMinus}$42`, params)).toBe(-42); }); }, ); }); it('-$0.42', () => { expect(maskitoParseNumber(`${CHAR_MINUS}$0.42`, params)).toBe(-0.42); }); it('-$.42', () => { expect(maskitoParseNumber('-$.42', params)).toBe(-0.42); }); it('-$0', () => { expect(maskitoParseNumber('-$0', params)).toBe(-0); }); it('-$', () => { expect(maskitoParseNumber(`${CHAR_MINUS}$`, params)).toBeNaN(); }); }); describe('NaN', () => { it('empty string => NaN', () => { expect(maskitoParseNumber('')).toBeNaN(); }); it('decimal separator only => NaN', () => { expect(maskitoParseNumber('.')).toBeNaN(); expect(maskitoParseNumber(',', {decimalSeparator: ','})).toBeNaN(); }); it('negative sign only => NaN', () => { expect(maskitoParseNumber(CHAR_MINUS)).toBeNaN(); expect(maskitoParseNumber(CHAR_HYPHEN)).toBeNaN(); expect(maskitoParseNumber(CHAR_EN_DASH)).toBeNaN(); expect(maskitoParseNumber(CHAR_EM_DASH)).toBeNaN(); }); }); }); ================================================ FILE: projects/kit/src/lib/masks/number/utils/tests/stringify-number-without-exp.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {stringifyNumberWithoutExp} from '../stringify-number-without-exp'; describe('number converting to string without exponent', () => { it('value with exponent and without fractional part and precision > 6', () => { expect(stringifyNumberWithoutExp(1e-10)).toBe('0.0000000001'); }); it('value with exponent and fractional part and precision > 6', () => { expect(stringifyNumberWithoutExp(1.23e-8)).toBe('0.0000000123'); }); it('negative value with exponent and fractional part and precision > 6', () => { expect(stringifyNumberWithoutExp(-1.23e-8)).toBe('-0.0000000123'); }); it('integer value', () => { expect(stringifyNumberWithoutExp(1)).toBe('1'); }); it('integer value with zeros', () => { expect(stringifyNumberWithoutExp(100)).toBe('100'); }); it('fractional value without exponent', () => { expect(stringifyNumberWithoutExp(0.111)).toBe('0.111'); }); it('negative integer value', () => { expect(stringifyNumberWithoutExp(-100)).toBe('-100'); }); it('negative fractional value', () => { expect(stringifyNumberWithoutExp(-1e-2)).toBe('-0.01'); }); it('fractional value with exponent and precision equals 4', () => { expect(stringifyNumberWithoutExp(2.23e-2)).toBe('0.0223'); }); it('very small exponent that exceeds toFixed limit must expand manually', () => { expect(stringifyNumberWithoutExp(102.282e-112)).toBe( '0.0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000102282', ); }); it('positive exponent should use full wide expansion', () => { expect(stringifyNumberWithoutExp(1e25)).toBe('10000000000000000000000000'); }); it('zero', () => { expect(stringifyNumberWithoutExp(0)).toBe('0'); }); it('negative zero formatted correctly', () => { expect(stringifyNumberWithoutExp(-0)).toBe('0'); }); it('bigint basic', () => { expect(stringifyNumberWithoutExp(123n)).toBe('123'); }); it('negative bigint', () => { expect(stringifyNumberWithoutExp(-999999999999999999999n)).toBe( '-999999999999999999999', ); }); it('large negative exponent simple case', () => { expect(stringifyNumberWithoutExp(5e-5)).toBe('0.00005'); }); it('decimal with many zeros inside exponent', () => { expect(stringifyNumberWithoutExp(9.001e-4)).toBe('0.0009001'); }); it('positive exponent with fractional part', () => { expect(stringifyNumberWithoutExp(3.14e5)).toBe('314000'); }); }); ================================================ FILE: projects/kit/src/lib/masks/number/utils/tests/stringify-number.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import { CHAR_EM_DASH, CHAR_EN_DASH, CHAR_HYPHEN, CHAR_JP_HYPHEN, CHAR_MINUS, } from '../../../../constants'; import type {MaskitoNumberParams} from '../../number-params'; import {maskitoStringifyNumber} from '../stringify-number'; describe('maskitoStringifyNumber', () => { describe('decimal separator is dot (default one)', () => { it('thousand separator is space', () => { expect( maskitoStringifyNumber(1000000.42, { maximumFractionDigits: 2, decimalSeparator: '.', thousandSeparator: ' ', }), ).toBe('1 000 000.42'); }); it('thousand separator is hyphen', () => { expect( maskitoStringifyNumber(1000000.42, { maximumFractionDigits: 2, decimalSeparator: '.', thousandSeparator: '-', }), ).toBe('1-000-000.42'); }); it('thousand separator is empty string', () => { expect( maskitoStringifyNumber(1000000.42, { maximumFractionDigits: 2, thousandSeparator: '', decimalSeparator: '.', }), ).toBe('1000000.42'); }); it('empty decimal part & thousand separator is comma', () => { expect(maskitoStringifyNumber(1000000, {thousandSeparator: ','})).toBe( '1,000,000', ); }); it('trailing decimal separator (minimumFractionDigits > maximumFractionDigits => maximumFractionDigits has more priority)', () => { expect( maskitoStringifyNumber(0, { maximumFractionDigits: 0, decimalSeparator: '.', minimumFractionDigits: 2, }), ).toBe('0'); }); it('trailing decimal separator maximumFractionDigits', () => { expect( maskitoStringifyNumber(0, { maximumFractionDigits: 2, decimalSeparator: '.', minimumFractionDigits: 2, }), ).toBe('0.00'); }); it('number with exponential notation', () => { expect(maskitoStringifyNumber(0.000000012, {maximumFractionDigits: 10})).toBe( '0.000000012', ); expect( maskitoStringifyNumber(1.2e-8, {maximumFractionDigits: 10, prefix: '$'}), ).toBe('$0.000000012'); expect(maskitoStringifyNumber(2e24, {thousandSeparator: '_'})).toBe( '2_000_000_000_000_000_000_000_000', ); }); }); describe('decimal separator is comma', () => { it('thousand separator is space', () => { expect( maskitoStringifyNumber(42111.42, { maximumFractionDigits: 2, decimalSeparator: ',', thousandSeparator: ' ', }), ).toBe('42 111,42'); }); it('thousand separator is hyphen', () => { expect( maskitoStringifyNumber(42111.42, { maximumFractionDigits: 2, decimalSeparator: ',', thousandSeparator: '-', }), ).toBe('42-111,42'); }); it('thousand separator is empty string', () => { expect( maskitoStringifyNumber(42111.42, { maximumFractionDigits: 2, decimalSeparator: ',', thousandSeparator: '', }), ).toBe('42111,42'); }); it('empty decimal part & thousand separator is dot', () => { expect( maskitoStringifyNumber(42111, { decimalSeparator: ',', thousandSeparator: '.', }), ).toBe('42.111'); }); it('trailing decimal separator (minimumFractionDigits > maximumFractionDigits => maximumFractionDigits has more priority)', () => { expect( maskitoStringifyNumber(42, { maximumFractionDigits: 0, decimalSeparator: ',', minimumFractionDigits: 2, }), ).toBe('42'); }); it('with decimal part', () => { expect( maskitoStringifyNumber(42.1, { decimalSeparator: ',', thousandSeparator: '.', maximumFractionDigits: 2, }), ).toBe('42,1'); }); it('zero-padded fraction part', () => { expect( maskitoStringifyNumber(42, { maximumFractionDigits: 2, decimalSeparator: ',', minimumFractionDigits: 2, }), ).toBe('42,00'); }); }); describe('negative numbers', () => { describe('minus sign', () => { it('can be minus', () => { expect(maskitoStringifyNumber(-1, {minusSign: CHAR_MINUS})).toBe( `${CHAR_MINUS}1`, ); }); it('can be hyphen', () => { expect( maskitoStringifyNumber(-123456, { minusSign: CHAR_HYPHEN, thousandSeparator: ' ', }), ).toBe(`${CHAR_HYPHEN}123 456`); }); it('can be en-dash', () => { expect( maskitoStringifyNumber(-123456789, { minusSign: CHAR_EN_DASH, thousandSeparator: ' ', }), ).toBe(`${CHAR_EN_DASH}123 456 789`); }); it('can be em-dash', () => { expect(maskitoStringifyNumber(-42, {minusSign: CHAR_EM_DASH})).toBe( `${CHAR_EM_DASH}42`, ); }); it('can be katakana-hiragana prolonged sound mark', () => { expect(maskitoStringifyNumber(-42, {minusSign: CHAR_JP_HYPHEN})).toBe( `${CHAR_JP_HYPHEN}42`, ); }); }); it('stringifies negative integer number when thousand separator is hyphen & minus sign is hyphen', () => { expect( maskitoStringifyNumber(-123456, { thousandSeparator: '-', minusSign: '-', }), ).toBe('-123-456'); }); it('stringifies negative number with decimal part', () => { expect(maskitoStringifyNumber(-123.456, {maximumFractionDigits: 3})).toBe( `${CHAR_MINUS}123.456`, ); }); }); describe('Prefix & Postfix', () => { it('stringifies number with only prefix', () => { expect(maskitoStringifyNumber(42, {prefix: '$'})).toBe('$42'); }); it('stringifies number with only postfix', () => { expect(maskitoStringifyNumber(42, {postfix: '%'})).toBe('42%'); }); it('stringifies number with both prefix and postfix', () => { expect( maskitoStringifyNumber(42, { prefix: '$', postfix: ' per day', }), ).toBe('$42 per day'); }); it('stringifies negative number with prefix', () => { expect(maskitoStringifyNumber(-42, {prefix: '>'})).toBe(`>${CHAR_MINUS}42`); }); describe('prefix/postfix includes point and space', () => { it('stringifies INTEGER number with postfix " lbs."', () => { expect(maskitoStringifyNumber(42, {postfix: ' lbs.'})).toBe('42 lbs.'); expect( maskitoStringifyNumber(1000, { thousandSeparator: ' ', postfix: ' lbs.', }), ).toBe('1 000 lbs.'); }); it('stringifies DECIMAL number with postfix " lbs."', () => { expect( maskitoStringifyNumber(0.42, { maximumFractionDigits: 2, postfix: ' lbs.', }), ).toBe('0.42 lbs.'); expect( maskitoStringifyNumber(1000.42, { maximumFractionDigits: 2, thousandSeparator: ' ', postfix: ' lbs.', }), ).toBe('1 000.42 lbs.'); expect( maskitoStringifyNumber(1000, { maximumFractionDigits: 0, minimumFractionDigits: 2, thousandSeparator: ' ', postfix: ' lbs.', }), ).toBe('1 000 lbs.'); }); it('stringifies INTEGER number with prefix "lbs. "', () => { expect(maskitoStringifyNumber(42, {prefix: 'lbs. '})).toBe('lbs. 42'); expect( maskitoStringifyNumber(1000, { thousandSeparator: ' ', prefix: 'lbs. ', }), ).toBe('lbs. 1 000'); }); it('stringifies DECIMAL number with prefix "lbs. "', () => { expect( maskitoStringifyNumber(0.42, { maximumFractionDigits: 2, prefix: 'lbs. ', }), ).toBe('lbs. 0.42'); expect( maskitoStringifyNumber(1000.42, { maximumFractionDigits: 2, thousandSeparator: ' ', prefix: 'lbs. ', }), ).toBe('lbs. 1 000.42'); }); }); }); describe('Minus is positioned before prefix', () => { const params: MaskitoNumberParams = { decimalSeparator: ',', minusSign: CHAR_MINUS, prefix: '$', negativePattern: 'minusFirst', maximumFractionDigits: 2, }; it('-42 => -$42', () => { expect(maskitoStringifyNumber(-42, params)).toBe(`${CHAR_MINUS}$42`); }); it('-0.42 => -$0,42', () => { expect(maskitoStringifyNumber(-0.42, params)).toBe(`${CHAR_MINUS}$0,42`); }); it('0 => $0', () => { expect(maskitoStringifyNumber(0, params)).toBe('$0'); }); it('NaN', () => { expect(maskitoStringifyNumber(Number.NaN, params)).toBe(''); }); }); describe('Min and Max constraints', () => { it('applies min constraint', () => { expect(maskitoStringifyNumber(-10, {min: 0})).toBe('0'); }); it('applies max constraint', () => { expect(maskitoStringifyNumber(1000, {max: 100})).toBe('100'); }); it('applies both min and max constraints', () => { expect( maskitoStringifyNumber(150, { min: 0, max: 100, }), ).toBe('100'); expect( maskitoStringifyNumber(-50, { min: 0, max: 100, }), ).toBe('0'); expect( maskitoStringifyNumber(50, { min: 0, max: 100, }), ).toBe('50'); }); }); describe('[maximumFractionDigits] handling', () => { it('handles zero maximumFractionDigits correctly', () => { expect(maskitoStringifyNumber(123.456, {maximumFractionDigits: 0})).toBe( '123', ); }); it('handles custom maximumFractionDigits correctly', () => { expect(maskitoStringifyNumber(123.456, {maximumFractionDigits: 1})).toBe( '123.4', ); expect(maskitoStringifyNumber(123.456, {maximumFractionDigits: 4})).toBe( '123.456', ); }); it('handles zero padding correctly', () => { expect( maskitoStringifyNumber(123, { maximumFractionDigits: 2, minimumFractionDigits: 2, }), ).toBe('123.00'); expect( maskitoStringifyNumber(123, { maximumFractionDigits: 0, minimumFractionDigits: 0, }), ).toBe('123'); }); }); describe('BigInt support', () => { it('stringifies BigInt values larger than Number.MAX_SAFE_INTEGER', () => { expect( maskitoStringifyNumber( BigInt(String(Number.MAX_SAFE_INTEGER).repeat(2)), {thousandSeparator: ' '}, ), ).toBe('90 071 992 547 409 919 007 199 254 740 991'); }); it('clamps values using bigint-powered min/max', () => { expect( maskitoStringifyNumber(100n, { min: -5n, max: 5n, }), ).toBe('5'); expect( maskitoStringifyNumber(-100n, { min: -5n, max: 5n, }), ).toBe(`${CHAR_MINUS}5`); expect( maskitoStringifyNumber(10, { min: -5n, max: 5n, }), ).toBe('5'); expect( maskitoStringifyNumber(10n, { min: -5, max: 5, }), ).toBe('5'); }); it('pads fractional part for bigint inputs', () => { expect( maskitoStringifyNumber(42n, { thousandSeparator: '', maximumFractionDigits: 2, minimumFractionDigits: 2, }), ).toBe('42.00'); }); it('works with prefix', () => { expect( maskitoStringifyNumber(1000000000000n, { prefix: '$', thousandSeparator: '_', }), ).toBe('$1_000_000_000_000'); }); it('works with postfix', () => { expect( maskitoStringifyNumber(-5000n, { postfix: ' pcs', thousandSeparator: '_', }), ).toBe(`${CHAR_MINUS}5_000 pcs`); }); }); }); ================================================ FILE: projects/kit/src/lib/masks/number/utils/tests/to-number-parts.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import type {MaskitoNumberParams} from '@maskito/kit'; import { CHAR_EM_DASH, CHAR_EN_DASH, CHAR_HYPHEN, CHAR_JP_HYPHEN, CHAR_MINUS, DEFAULT_PSEUDO_MINUSES, } from '../../../../constants'; import {toNumberParts} from '../number-parts'; const DEFAULT_PARAMS = { prefix: '', postfix: '', minusPseudoSigns: DEFAULT_PSEUDO_MINUSES, decimalSeparator: '.', minusSign: '-', maximumFractionDigits: 0, decimalPseudoSeparators: [','], } as const satisfies MaskitoNumberParams; describe('toNumberParts', () => { [',', '.'].forEach((decimalSeparator) => { describe(`decimalSeparator = ${decimalSeparator}`, () => { const params = { ...DEFAULT_PARAMS, minusSign: '-', maximumFractionDigits: 2, decimalSeparator, } as const satisfies MaskitoNumberParams; it('empty string => empty parts', () => { expect(toNumberParts('', params)).toEqual({ minus: '', integerPart: '', decimalPart: '', decimalSeparator: '', prefix: '', postfix: '', }); }); it(`123${decimalSeparator}45 => {minus: "", integerPart: "123", decimalPart: "45"}`, () => { expect(toNumberParts(`123${decimalSeparator}45`, params)).toEqual({ minus: '', integerPart: '123', decimalPart: '45', decimalSeparator, prefix: '', postfix: '', }); }); it(`-123${decimalSeparator}45 => {minus: "-", integerPart: "123", decimalPart: "45"}`, () => { expect(toNumberParts(`-123${decimalSeparator}45`, params)).toEqual({ minus: '-', integerPart: '123', decimalPart: '45', decimalSeparator, prefix: '', postfix: '', }); }); it('123 => {minus: "", integerPart: "123", decimalPart: ""}', () => { expect(toNumberParts('123', params)).toEqual({ minus: '', integerPart: '123', decimalPart: '', decimalSeparator: '', prefix: '', postfix: '', }); }); it('-123 => {minus: "-", integerPart: "123", decimalPart: ""}', () => { expect(toNumberParts('-123', params)).toEqual({ minus: '-', integerPart: '123', decimalPart: '', decimalSeparator: '', prefix: '', postfix: '', }); }); it(`${decimalSeparator}45 => {minus: "", integerPart: "", decimalPart: "45"}`, () => { expect(toNumberParts(`${decimalSeparator}45`, params)).toEqual({ minus: '', integerPart: '', decimalPart: '45', decimalSeparator, prefix: '', postfix: '', }); }); it(`-${decimalSeparator}45 => {minus: "-", integerPart: "", decimalPart: "45"}`, () => { expect(toNumberParts(`-${decimalSeparator}45`, params)).toEqual({ minus: '-', integerPart: '', decimalPart: '45', decimalSeparator, prefix: '', postfix: '', }); }); it('- => {minus: "-", integerPart: "", decimalPart: ""}', () => { expect(toNumberParts('-', params)).toEqual({ minus: '-', integerPart: '', decimalPart: '', decimalSeparator: '', prefix: '', postfix: '', }); }); }); }); describe('different minus signs', () => { [CHAR_MINUS, CHAR_HYPHEN, CHAR_EN_DASH, CHAR_EM_DASH, CHAR_JP_HYPHEN].forEach( (minus) => { const params = { ...DEFAULT_PARAMS, minusSign: minus, maximumFractionDigits: 2, decimalSeparator: '.', decimalPseudoSeparators: ['б'], thousandSeparator: ',', } as const satisfies MaskitoNumberParams; it(minus, () => { expect(toNumberParts(`${minus}1,234,567.89`, params)).toEqual({ minus, integerPart: '1,234,567', decimalPart: '89', decimalSeparator: '.', prefix: '', postfix: '', }); }); }, ); }); describe('thousand separator (e.g. underscore) is a part of integer', () => { const thousandSeparator = '_'; const params = { ...DEFAULT_PARAMS, minusSign: '-', thousandSeparator, maximumFractionDigits: 2, decimalSeparator: '.', } as const satisfies MaskitoNumberParams; it('only thousand separator sign', () => { expect(toNumberParts(thousandSeparator, params)).toEqual({ minus: '', integerPart: '_', decimalPart: '', decimalSeparator: '', prefix: '', postfix: '', }); }); it('only minus and thousand separator signs', () => { expect(toNumberParts(`-${thousandSeparator}`, params)).toEqual({ minus: '-', integerPart: thousandSeparator, decimalPart: '', decimalSeparator: '', prefix: '', postfix: '', }); }); it(`-1${thousandSeparator}234.45 => {minus: "-", integerPart: "1${thousandSeparator}234", decimalPart: "45"}`, () => { expect(toNumberParts(`-1${thousandSeparator}234.45`, params)).toEqual({ minus: '-', integerPart: `1${thousandSeparator}234`, decimalPart: '45', decimalSeparator: '.', prefix: '', postfix: '', }); }); it(`-${thousandSeparator}234.45 => {minus: "-", integerPart: "${thousandSeparator}234", decimalPart: "45"}`, () => { expect(toNumberParts(`-${thousandSeparator}234.45`, params)).toEqual({ minus: '-', integerPart: `${thousandSeparator}234`, decimalPart: '45', decimalSeparator: '.', prefix: '', postfix: '', }); }); }); describe('multi-character affixes', () => { describe('prefix = EUR', () => { const params = { ...DEFAULT_PARAMS, prefix: 'EUR', } as const satisfies MaskitoNumberParams; ['E', 'U', 'R'].forEach((char) => { it(`type single character ${char}`, () => { expect(toNumberParts(char, params)).toEqual({ minus: '', integerPart: '', decimalPart: '', decimalSeparator: '', prefix: char, postfix: '', }); }); }); }); describe('postfix = руб.', () => { const params = { ...DEFAULT_PARAMS, postfix: 'руб.', // ends with point! decimalSeparator: '.', // point too! maximumFractionDigits: 2, } as const satisfies MaskitoNumberParams; ['р', 'у', 'б'].forEach((char) => { it(`type 1 + single character ${char}`, () => { expect(toNumberParts(`1${char}`, params)).toEqual({ minus: '', integerPart: '1', decimalPart: '', decimalSeparator: '', prefix: '', postfix: char, }); }); }); it('type 1 + point', () => { expect(toNumberParts('1.', params)).toEqual({ minus: '', integerPart: '1', decimalPart: '', decimalSeparator: '.', prefix: '', postfix: '', }); }); it('100руб.', () => { expect(toNumberParts('100руб.', params)).toEqual({ minus: '', integerPart: '100', decimalPart: '', decimalSeparator: '', prefix: '', postfix: 'руб.', }); }); }); }); it('prefix ends with point & value starts with decimal point too', () => { expect( toNumberParts('.42', { ...DEFAULT_PARAMS, decimalSeparator: '.', prefix: 'lbs.', maximumFractionDigits: 2, }), ).toEqual({ minus: '', integerPart: '', decimalPart: '42', decimalSeparator: '.', prefix: '', postfix: '', }); }); it('postfix contains point & value ends with decimal point too', () => { expect( toNumberParts('123.lbs.', { ...DEFAULT_PARAMS, maximumFractionDigits: 2, postfix: 'lbs.', }), ).toEqual({ minus: '', integerPart: '123', decimalPart: '', decimalSeparator: '.', prefix: '', postfix: 'lbs.', }); }); describe('postfix starts with point | [postfix]=".000 km" & [maximumFractionDigits]="0"', () => { const postfix = '.000 km'; const params = { ...DEFAULT_PARAMS, maximumFractionDigits: 0, postfix, } as const; it('1.000 km', () => { expect(toNumberParts('1.000 km', params)).toEqual({ minus: '', integerPart: '1', decimalPart: '', decimalSeparator: '', prefix: '', postfix: '.000 km', }); }); it('.000 km', () => { expect(toNumberParts('.000 km', params)).toEqual({ minus: '', integerPart: '', decimalPart: '', decimalSeparator: '', prefix: '', postfix: '.000 km', }); }); it('1000.', () => { expect(toNumberParts('1000.', params)).toEqual({ minus: '', integerPart: '1000', decimalPart: '', decimalSeparator: '', prefix: '', postfix: '.', }); }); it('100.000 km', () => { expect(toNumberParts('100.000 km', params)).toEqual({ minus: '', integerPart: '100', decimalPart: '', decimalSeparator: '', prefix: '', postfix: '.000 km', }); }); it('100 (zeroes can be both incomplete postfix & integer part => for ambiguous case regard it as integer part)', () => { expect(toNumberParts('100', params)).toEqual({ minus: '', integerPart: '100', decimalPart: '', decimalSeparator: '', prefix: '', postfix: '', }); }); }); describe('prefix & minus can be swapped', () => { it('>-123 => {prefix: ">", minus: "-"}', () => { expect( toNumberParts('>-123', { ...DEFAULT_PARAMS, prefix: '>', }), ).toEqual({ minus: '-', integerPart: '123', decimalPart: '', decimalSeparator: '', prefix: '>', postfix: '', }); }); it('-$123 => {prefix: "$", minus: "-", negativePattern: "minusFirst"}', () => { expect( toNumberParts('-$123', { ...DEFAULT_PARAMS, prefix: '$', }), ).toEqual({ minus: '-', integerPart: '123', decimalPart: '', decimalSeparator: '', prefix: '$', postfix: '', }); }); }); }); ================================================ FILE: projects/kit/src/lib/masks/number/utils/tests/validate-decimal-pseudo-separators.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {DEFAULT_DECIMAL_PSEUDO_SEPARATORS} from '../../../../constants'; import {validateDecimalPseudoSeparators} from '../validate-decimal-pseudo-separators'; describe('validate decimal pseudo separators or return default', () => { it('should return no empty array if decimalPseudoSeparators === `undefined`', () => { expect( validateDecimalPseudoSeparators({ decimalSeparator: ',', thousandSeparator: ' ', }), ).toEqual(DEFAULT_DECIMAL_PSEUDO_SEPARATORS.filter((char) => char !== ',')); }); it('should exclude decimalSeparator and thousandSeparator from decimalPseudoSeparators', () => { const decimalPseudoSeparators = [',', '.', 'a', 'b']; const decimalSeparator = 'a'; const thousandSeparator = 'b'; expect( validateDecimalPseudoSeparators({ decimalSeparator, thousandSeparator, decimalPseudoSeparators, }), ).toEqual([',', '.']); }); it('should return original decimalPseudoSeparators', () => { const decimalPseudoSeparators = [',', 'б', 'ю']; const decimalSeparator = '.'; const thousandSeparator = ' '; expect( validateDecimalPseudoSeparators({ decimalSeparator, thousandSeparator, decimalPseudoSeparators, }), ).toEqual(decimalPseudoSeparators); }); }); ================================================ FILE: projects/kit/src/lib/masks/number/utils/validate-decimal-pseudo-separators.ts ================================================ import {DEFAULT_DECIMAL_PSEUDO_SEPARATORS} from '../../../constants'; export function validateDecimalPseudoSeparators({ decimalSeparator, thousandSeparator, decimalPseudoSeparators = DEFAULT_DECIMAL_PSEUDO_SEPARATORS, }: { decimalSeparator: string; thousandSeparator: string; decimalPseudoSeparators?: readonly string[]; }): string[] { return decimalPseudoSeparators.filter( (char) => char !== thousandSeparator && char !== decimalSeparator, ); } ================================================ FILE: projects/kit/src/lib/masks/number/utils/with-number-defaults.ts ================================================ import { CHAR_MINUS, CHAR_NO_BREAK_SPACE, CHAR_ZERO_WIDTH_SPACE, DEFAULT_PSEUDO_MINUSES, } from '../../../constants'; import {type MaskitoNumberParams} from '../number-params'; import {validateDecimalPseudoSeparators} from './validate-decimal-pseudo-separators'; export function withNumberDefaults({ max = Infinity, min = -Infinity, thousandSeparator = CHAR_NO_BREAK_SPACE, thousandSeparatorPattern = (x) => x.match(/\d{1,3}(?=(?:\d{3})*$)/g) ?? [], decimalSeparator = '.', decimalPseudoSeparators: unsafeDecimalPseudoSeparators, prefix = '', postfix = '', minusSign = CHAR_MINUS, minusPseudoSigns = DEFAULT_PSEUDO_MINUSES.filter( (char) => char !== thousandSeparator && char !== decimalSeparator && char !== minusSign, ), maximumFractionDigits = 0, minimumFractionDigits = 0, negativePattern = 'prefixFirst', }: MaskitoNumberParams = {}): Required { const decimalPseudoSeparators = validateDecimalPseudoSeparators({ decimalSeparator, thousandSeparator, decimalPseudoSeparators: unsafeDecimalPseudoSeparators, }); return { max, min, thousandSeparator, thousandSeparatorPattern, postfix, minusSign, minusPseudoSigns, maximumFractionDigits, decimalPseudoSeparators, negativePattern, decimalSeparator: maximumFractionDigits <= 0 && decimalSeparator === thousandSeparator ? '' : decimalSeparator, prefix: prefix.endsWith(decimalSeparator) && maximumFractionDigits > 0 ? `${prefix}${CHAR_ZERO_WIDTH_SPACE}` : prefix, minimumFractionDigits: Math.min(minimumFractionDigits, maximumFractionDigits), }; } ================================================ FILE: projects/kit/src/lib/masks/time/index.ts ================================================ export * from './time-mask'; export * from './time-params'; export * from './utils'; ================================================ FILE: projects/kit/src/lib/masks/time/time-mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import { DEFAULT_TIME_SEGMENT_MAX_VALUES, DEFAULT_TIME_SEGMENT_MIN_VALUES, } from '../../constants'; import { createMeridiemSteppingPlugin, createTimeSegmentsSteppingPlugin, } from '../../plugins'; import { createColonConvertPreprocessor, createFullWidthToHalfWidthPreprocessor, createInvalidTimeSegmentInsertionPreprocessor, createMeridiemPostprocessor, createMeridiemPreprocessor, createZeroPlaceholdersPreprocessor, maskitoPostfixPostprocessorGenerator, maskitoPrefixPostprocessorGenerator, } from '../../processors'; import type {MaskitoTimeSegments} from '../../types'; import {createTimeMaskExpression, enrichTimeSegmentsWithZeroes} from '../../utils/time'; import type {MaskitoTimeParams} from './time-params'; export function maskitoTimeOptionsGenerator({ mode, timeSegmentMaxValues = {}, timeSegmentMinValues = {}, step = 0, prefix = '', postfix = '', }: MaskitoTimeParams): Required { const hasMeridiem = mode.includes('AA'); const enrichedTimeSegmentMaxValues: MaskitoTimeSegments = { ...DEFAULT_TIME_SEGMENT_MAX_VALUES, ...(hasMeridiem ? {hours: 12} : {}), ...timeSegmentMaxValues, }; const enrichedTimeSegmentMinValues: MaskitoTimeSegments = { ...DEFAULT_TIME_SEGMENT_MIN_VALUES, ...(hasMeridiem ? {hours: 1} : {}), ...timeSegmentMinValues, }; const maskExpression = [...prefix, ...createTimeMaskExpression(mode)]; return { mask: postfix ? ({value}) => cutExpression(maskExpression, value).concat(...postfix) : maskExpression, preprocessors: [ createFullWidthToHalfWidthPreprocessor(), createColonConvertPreprocessor(), createZeroPlaceholdersPreprocessor(postfix), createMeridiemPreprocessor(mode), createInvalidTimeSegmentInsertionPreprocessor({ timeMode: mode, timeSegmentMinValues: enrichedTimeSegmentMinValues, timeSegmentMaxValues: enrichedTimeSegmentMaxValues, }), ], postprocessors: [ createMeridiemPostprocessor(mode), (elementState) => enrichTimeSegmentsWithZeroes(elementState, { mode, timeSegmentMaxValues: enrichedTimeSegmentMaxValues, }), maskitoPrefixPostprocessorGenerator(prefix), maskitoPostfixPostprocessorGenerator(postfix), ], plugins: [ createTimeSegmentsSteppingPlugin({ fullMode: mode, step, timeSegmentMinValues: enrichedTimeSegmentMinValues, timeSegmentMaxValues: enrichedTimeSegmentMaxValues, }), createMeridiemSteppingPlugin(mode.indexOf('AA')), ], overwriteMode: 'replace', }; } /** * Without cutting, the mask expression removes postfix on the last digit deletion * ___ * Case 1 (static pattern mask expression) * Mask expression is [/\d/, /\d/, ':', /\d/, /\d/, ' left'] * 12:34| left => Press Backspace => 12:3| * Mask correctly removes postfix because it's fixed characters after not yet inserted 4th digit. * ___ * Case 2 (dynamic pattern mask expression) * Mask expression is [/\d/, /\d/, ':', /\d/, /\d/, ' left'] & textfield contains `12:34 left` * 12:34| left => Press Backspace => Mask expression becomes [/\d/, /\d/, ':', /\d/, ' left'] => 12:3| left * Mask correctly does not remove postfix because it's trailing fixed characters * and all non-fixed characters were already inserted. */ function cutExpression( expression: Array, value: string, ): Array { let digitsCount = Math.min( value.replaceAll(/\D/g, '').length, expression.filter((x) => typeof x !== 'string').length, ) || 1; const afterLastDigit = expression.findIndex((x) => typeof x !== 'string' && !--digitsCount) + 1; return expression.slice(0, afterLastDigit); } ================================================ FILE: projects/kit/src/lib/masks/time/time-params.ts ================================================ import type {MaskitoTimeMode, MaskitoTimeSegments} from '../../types'; export interface MaskitoTimeParams { readonly mode: MaskitoTimeMode; readonly timeSegmentMaxValues?: Partial>; readonly timeSegmentMinValues?: Partial>; readonly step?: number; readonly prefix?: string; readonly postfix?: string; } ================================================ FILE: projects/kit/src/lib/masks/time/utils/index.ts ================================================ export * from './parse-time'; export * from './stringify-time'; ================================================ FILE: projects/kit/src/lib/masks/time/utils/parse-time.ts ================================================ import {DEFAULT_TIME_SEGMENT_MAX_VALUES} from '../../../constants'; import type {MaskitoTimeSegments} from '../../../types'; import {padEndTimeSegments, parseTimeString} from '../../../utils/time'; import type {MaskitoTimeParams} from '../time-params'; /** * Converts a formatted time string to milliseconds based on the given `options.mode`. * * @param maskedTime a formatted time string by {@link maskitoTimeOptionsGenerator} or {@link maskitoStringifyTime} * @param params */ export function maskitoParseTime( maskedTime: string, {mode, timeSegmentMaxValues = {}}: MaskitoTimeParams, ): number { const maxValues: MaskitoTimeSegments = { ...DEFAULT_TIME_SEGMENT_MAX_VALUES, ...timeSegmentMaxValues, }; const msInSecond = maxValues.milliseconds + 1; const msInMinute = (maxValues.seconds + 1) * msInSecond; const msInHour = (maxValues.minutes + 1) * msInMinute; const parsedTime = padEndTimeSegments(parseTimeString(maskedTime, mode)); let hours = Number(parsedTime.hours ?? ''); if (mode.includes('AA') && Number.isFinite(hours)) { if (maskedTime.includes('PM')) { hours = hours < 12 ? hours + 12 : hours; } else { hours = hours === 12 ? 0 : hours; } } return ( hours * msInHour + Number(parsedTime.minutes ?? '') * msInMinute + Number(parsedTime.seconds ?? '') * msInSecond + Number(parsedTime.milliseconds ?? '') ); } ================================================ FILE: projects/kit/src/lib/masks/time/utils/stringify-time.ts ================================================ import {DEFAULT_TIME_SEGMENT_MAX_VALUES} from '../../../constants'; import type {MaskitoTimeSegments} from '../../../types'; import {padStartTimeSegments} from '../../../utils/time'; import type {MaskitoTimeParams} from '../time-params'; /** * Converts milliseconds to a formatted time string based on the given `options.mode`. * * @param milliseconds unsigned integer milliseconds * @param params */ export function maskitoStringifyTime( milliseconds: number, {mode, timeSegmentMaxValues = {}}: MaskitoTimeParams, ): string { const maxValues: MaskitoTimeSegments = { ...DEFAULT_TIME_SEGMENT_MAX_VALUES, ...timeSegmentMaxValues, }; const hasMeridiem = mode.includes('AA'); const msInSecond = maxValues.milliseconds + 1; const msInMinute = (maxValues.seconds + 1) * msInSecond; const msInHour = (maxValues.minutes + 1) * msInMinute; const hours = Math.trunc(milliseconds / msInHour); milliseconds -= hours * msInHour; const minutes = Math.trunc(milliseconds / msInMinute); milliseconds -= minutes * msInMinute; const seconds = Math.trunc(milliseconds / msInSecond); milliseconds -= seconds * msInSecond; const result = padStartTimeSegments({ hours: hasMeridiem ? hours % 12 || 12 : hours, minutes, seconds, milliseconds, }); return mode .replaceAll(/H+/g, result.hours) .replaceAll('MSS', result.milliseconds) .replaceAll(/M+/g, result.minutes) .replaceAll(/S+/g, result.seconds) .replace('AA', hours >= 12 ? 'PM' : 'AM'); } ================================================ FILE: projects/kit/src/lib/masks/time/utils/tests/parse-time.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {maskitoParseTime, type MaskitoTimeMode} from '@maskito/kit'; describe('maskitoParseTime', () => { const testCases = new Map>([ [ 'HH:MM:SS.MSS', [ {text: '', ms: 0}, {text: '00:00:00.000', ms: 0}, {text: '1', ms: 36000000}, {text: '10', ms: 36000000}, {text: '12', ms: 43200000}, {text: '12:', ms: 43200000}, {text: '12:3', ms: 45000000}, {text: '12:30', ms: 45000000}, {text: '12:34', ms: 45240000}, {text: '12:34:', ms: 45240000}, {text: '12:34:5', ms: 45290000}, {text: '12:34:50', ms: 45290000}, {text: '12:34:56', ms: 45296000}, {text: '12:34:56.', ms: 45296000}, {text: '12:34:56.7', ms: 45296700}, {text: '12:34:56.70', ms: 45296700}, {text: '12:34:56.700', ms: 45296700}, {text: '12:34:56.78', ms: 45296780}, {text: '12:34:56.780', ms: 45296780}, {text: '12:34:56.789', ms: 45296789}, {text: '23:59:59.999', ms: 86399999}, ], ], [ 'HH:MM:SS.MSS AA', [ {text: '', ms: 0}, {text: '12:00:00.000 AM', ms: 0}, {text: '01:00:00.000 AM', ms: 3600000}, {text: '11:59:59.999 AM', ms: 43199999}, {text: '12:00:00.000 PM', ms: 43200000}, {text: '01:00:00.000 PM', ms: 46800000}, {text: '11:59:59.999 PM', ms: 86399999}, ], ], [ 'HH:MM:SS', [ {text: '', ms: 0}, {text: '00:00:00', ms: 0}, {text: '1', ms: 36000000}, {text: '10', ms: 36000000}, {text: '12', ms: 43200000}, {text: '12:', ms: 43200000}, {text: '12:3', ms: 45000000}, {text: '12:30', ms: 45000000}, {text: '12:34', ms: 45240000}, {text: '12:34:', ms: 45240000}, {text: '12:34:5', ms: 45290000}, {text: '12:34:50', ms: 45290000}, {text: '12:34:56', ms: 45296000}, {text: '23:59:59', ms: 86399000}, ], ], [ 'HH:MM:SS AA', [ {text: '', ms: 0}, {text: '12:00:00 AM', ms: 0}, {text: '01:00:00 AM', ms: 3600000}, {text: '11:59:59 AM', ms: 43199000}, {text: '12:00:00 PM', ms: 43200000}, {text: '01:00:00 PM', ms: 46800000}, {text: '11:59:59 PM', ms: 86399000}, ], ], [ 'HH:MM', [ {text: '', ms: 0}, {text: '00:00', ms: 0}, {text: '1', ms: 36000000}, {text: '10', ms: 36000000}, {text: '12', ms: 43200000}, {text: '12:', ms: 43200000}, {text: '12:3', ms: 45000000}, {text: '12:30', ms: 45000000}, {text: '12:34', ms: 45240000}, {text: '23:59', ms: 86340000}, ], ], [ 'HH:MM AA', [ {text: '', ms: 0}, {text: '12:00 AM', ms: 0}, {text: '01:00 AM', ms: 3600000}, {text: '11:59 AM', ms: 43140000}, {text: '12:00 PM', ms: 43200000}, {text: '01:00 PM', ms: 46800000}, {text: '11:59 PM', ms: 86340000}, ], ], [ 'HH', [ {text: '', ms: 0}, {text: '00', ms: 0}, {text: '1', ms: 36000000}, {text: '10', ms: 36000000}, {text: '12', ms: 43200000}, {text: '23', ms: 82800000}, ], ], [ 'HH AA', [ {text: '', ms: 0}, {text: '12 AM', ms: 0}, {text: '01 AM', ms: 3600000}, {text: '11 AM', ms: 39600000}, {text: '12 PM', ms: 43200000}, {text: '01 PM', ms: 46800000}, {text: '11 PM', ms: 82800000}, ], ], [ 'MM:SS.MSS', [ {text: '', ms: 0}, {text: '00:00.000', ms: 0}, {text: '1', ms: 600000}, {text: '10', ms: 600000}, {text: '12', ms: 720000}, {text: '12.', ms: 720000}, {text: '12:3', ms: 750000}, {text: '12:30', ms: 750000}, {text: '12:34', ms: 754000}, {text: '12:34.', ms: 754000}, {text: '12:34.5', ms: 754500}, {text: '12:34.50', ms: 754500}, {text: '12:34.500', ms: 754500}, {text: '12:34.56', ms: 754560}, {text: '12:34.560', ms: 754560}, {text: '12:34.567', ms: 754567}, {text: '59:59.999', ms: 3599999}, ], ], [ 'MM:SS', [ {text: '', ms: 0}, {text: '1', ms: 600000}, {text: '10', ms: 600000}, {text: '12', ms: 720000}, {text: '12:', ms: 720000}, {text: '12:3', ms: 750000}, {text: '12:30', ms: 750000}, ], ], [ 'SS.MSS', [ {text: '', ms: 0}, {text: '00.000', ms: 0}, {text: '1', ms: 10000}, {text: '10', ms: 10000}, {text: '12', ms: 12000}, {text: '12.', ms: 12000}, {text: '12.3', ms: 12300}, {text: '12.30', ms: 12300}, {text: '12.300', ms: 12300}, {text: '12.34', ms: 12340}, {text: '12.340', ms: 12340}, {text: '12.345', ms: 12345}, {text: '59.999', ms: 59999}, ], ], ]); testCases.forEach((cases, mode) => { describe(`mode ${mode}`, () => { cases.forEach(({text, ms}) => { it(`'${text}' => ${ms}ms`, () => { expect(maskitoParseTime(text, {mode})).toBe(ms); }); }); }); }); }); ================================================ FILE: projects/kit/src/lib/masks/time/utils/tests/stringify-time.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {maskitoStringifyTime, type MaskitoTimeMode} from '@maskito/kit'; describe('maskitoStringifyTime', () => { const testCases = new Map>([ [ 'HH:MM:SS.MSS', [ {ms: 0, text: '00:00:00.000'}, {ms: 3661001, text: '01:01:01.001'}, {ms: 45296789, text: '12:34:56.789'}, {ms: 86399999, text: '23:59:59.999'}, ], ], [ 'HH:MM:SS.MSS AA', [ {ms: 0, text: '12:00:00.000 AM'}, {ms: 3600000, text: '01:00:00.000 AM'}, {ms: 43199999, text: '11:59:59.999 AM'}, {ms: 43200000, text: '12:00:00.000 PM'}, {ms: 46800000, text: '01:00:00.000 PM'}, {ms: 86399999, text: '11:59:59.999 PM'}, ], ], [ 'HH:MM:SS', [ {ms: 0, text: '00:00:00'}, {ms: 3661000, text: '01:01:01'}, {ms: 10920000, text: '03:02:00'}, {ms: 45296000, text: '12:34:56'}, {ms: 86399000, text: '23:59:59'}, ], ], [ 'HH:MM:SS AA', [ {ms: 0, text: '12:00:00 AM'}, {ms: 3600000, text: '01:00:00 AM'}, {ms: 43199000, text: '11:59:59 AM'}, {ms: 43200000, text: '12:00:00 PM'}, {ms: 46800000, text: '01:00:00 PM'}, {ms: 86399000, text: '11:59:59 PM'}, ], ], [ 'HH:MM', [ {ms: 0, text: '00:00'}, {ms: 3660000, text: '01:01'}, {ms: 45240000, text: '12:34'}, {ms: 86340000, text: '23:59'}, ], ], [ 'HH:MM AA', [ {ms: 0, text: '12:00 AM'}, {ms: 3600000, text: '01:00 AM'}, {ms: 43140000, text: '11:59 AM'}, {ms: 43200000, text: '12:00 PM'}, {ms: 46800000, text: '01:00 PM'}, {ms: 86340000, text: '11:59 PM'}, ], ], [ 'HH', [ {ms: 0, text: '00'}, {ms: 3600000, text: '01'}, {ms: 43200000, text: '12'}, {ms: 82800000, text: '23'}, ], ], [ 'HH AA', [ {ms: 0, text: '12 AM'}, {ms: 3600000, text: '01 AM'}, {ms: 39600000, text: '11 AM'}, {ms: 43200000, text: '12 PM'}, {ms: 46800000, text: '01 PM'}, {ms: 82800000, text: '11 PM'}, ], ], [ 'MM:SS.MSS', [ {ms: 0, text: '00:00.000'}, {ms: 61001, text: '01:01.001'}, {ms: 754567, text: '12:34.567'}, {ms: 3599999, text: '59:59.999'}, ], ], [ 'MM:SS', [ {ms: 0, text: '00:00'}, {ms: 60000, text: '01:00'}, {ms: 600000, text: '10:00'}, {ms: 750000, text: '12:30'}, ], ], [ 'SS.MSS', [ {ms: 0, text: '00.000'}, {ms: 1001, text: '01.001'}, {ms: 12345, text: '12.345'}, {ms: 59999, text: '59.999'}, ], ], ]); testCases.forEach((cases, mode) => { describe(`mode ${mode}`, () => { cases.forEach(({ms, text}) => { it(`${ms}ms => '${text}'`, () => { expect(maskitoStringifyTime(ms, {mode})).toBe(text); }); }); }); }); }); ================================================ FILE: projects/kit/src/lib/plugins/add-on-focus.ts ================================================ import {type MaskitoPlugin, maskitoUpdateElement} from '@maskito/core'; import {maskitoEventHandler} from './event-handler'; export function maskitoAddOnFocusPlugin(value: string): MaskitoPlugin { return maskitoEventHandler('focus', (element) => { if (!element.value) { maskitoUpdateElement(element, value); } }); } ================================================ FILE: projects/kit/src/lib/plugins/caret-guard.ts ================================================ import type {MaskitoPlugin} from '@maskito/core'; import {clamp} from '../utils'; import {maskitoSelectionChangeHandler} from './selection-change'; export function maskitoCaretGuard( guard: ( value: string, selection: readonly [from: number, to: number], ) => [from: number, to: number], ): MaskitoPlugin { return maskitoSelectionChangeHandler((element) => { const start = element.selectionStart ?? 0; const end = element.selectionEnd ?? 0; const [fromLimit, toLimit] = guard(element.value, [start, end]); if (fromLimit > start || toLimit < end) { element.setSelectionRange( clamp(start, fromLimit, toLimit), clamp(end, fromLimit, toLimit), ); } }); } ================================================ FILE: projects/kit/src/lib/plugins/event-handler.ts ================================================ import type {MaskitoElement, MaskitoOptions, MaskitoPlugin} from '@maskito/core'; export function maskitoEventHandler( name: string, handler: (element: MaskitoElement, options: Required) => void, eventListenerOptions?: AddEventListenerOptions, ): MaskitoPlugin { return (element, maskitoOptions) => { const listener = (): void => handler(element, maskitoOptions); element.addEventListener(name, listener, eventListenerOptions); return () => element.removeEventListener(name, listener, eventListenerOptions); }; } ================================================ FILE: projects/kit/src/lib/plugins/index.ts ================================================ export {maskitoAddOnFocusPlugin} from './add-on-focus'; export {maskitoCaretGuard} from './caret-guard'; export {maskitoEventHandler} from './event-handler'; export {maskitoRejectEvent} from './reject-event'; export {maskitoRemoveOnBlurPlugin} from './remove-on-blur'; export {maskitoSelectionChangeHandler} from './selection-change'; export {createMeridiemSteppingPlugin} from './time/meridiem-stepping'; export {createTimeSegmentsSteppingPlugin} from './time/time-segments-stepping'; ================================================ FILE: projects/kit/src/lib/plugins/reject-event.ts ================================================ import type {MaskitoPlugin} from '@maskito/core'; export const maskitoRejectEvent: MaskitoPlugin = (element) => { const listener = (): void => { const value = element.value; element.addEventListener( 'beforeinput', (event) => { if (event.defaultPrevented && value === element.value) { element.dispatchEvent( new CustomEvent('maskitoReject', {bubbles: true}), ); } }, {once: true}, ); }; element.addEventListener('beforeinput', listener, true); return () => element.removeEventListener('beforeinput', listener, true); }; ================================================ FILE: projects/kit/src/lib/plugins/remove-on-blur.ts ================================================ import {type MaskitoPlugin, maskitoUpdateElement} from '@maskito/core'; import {maskitoEventHandler} from './event-handler'; export function maskitoRemoveOnBlurPlugin(value: string): MaskitoPlugin { return maskitoEventHandler('blur', (element) => { if (element.value === value) { maskitoUpdateElement(element, ''); } }); } ================================================ FILE: projects/kit/src/lib/plugins/selection-change.ts ================================================ import type {MaskitoElement, MaskitoOptions, MaskitoPlugin} from '@maskito/core'; export function maskitoSelectionChangeHandler( handler: (element: MaskitoElement, options: Required) => void, ): MaskitoPlugin { return (element, options) => { const document = element.ownerDocument; let isPointerDown = 0; const onPointerDown = (): number => isPointerDown++; const onPointerUp = (): void => { isPointerDown = Math.max(--isPointerDown, 0); }; const listener = (): void => { if (!element.matches(':focus')) { return; } if (isPointerDown) { return document.addEventListener('mouseup', listener, { once: true, passive: true, }); } handler(element, options); }; document.addEventListener('selectionchange', listener, {passive: true}); // Safari does not fire `selectionchange` on focus after programmatic update of textfield value element.addEventListener('focus', listener, {passive: true}); element.addEventListener('mousedown', onPointerDown, {passive: true}); document.addEventListener('mouseup', onPointerUp, {passive: true}); return () => { document.removeEventListener('selectionchange', listener); element.removeEventListener('focus', listener); element.removeEventListener('mousedown', onPointerDown); document.removeEventListener('mouseup', onPointerUp); }; }; } ================================================ FILE: projects/kit/src/lib/plugins/time/meridiem-stepping.ts ================================================ import {type MaskitoPlugin, maskitoUpdateElement} from '@maskito/core'; import {ANY_MERIDIEM_CHARACTER_RE, CHAR_NO_BREAK_SPACE} from '../../constants'; import {noop} from '../../utils'; export function createMeridiemSteppingPlugin(meridiemStartIndex: number): MaskitoPlugin { if (meridiemStartIndex < 0) { return noop; } return (element) => { const listener = (event: KeyboardEvent): void => { const caretIndex = Number(element.selectionStart); const value = element.value.toUpperCase(); if ( (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') || caretIndex < meridiemStartIndex ) { return; } event.preventDefault(); // eslint-disable-next-line no-nested-ternary const meridiemMainCharacter = value.includes('A') ? 'P' : value.includes('P') || event.key === 'ArrowUp' ? 'A' : 'P'; const newMeridiem = `${CHAR_NO_BREAK_SPACE}${meridiemMainCharacter}M`; maskitoUpdateElement(element, { value: value.length === meridiemStartIndex ? `${value}${newMeridiem}` : value.replace(ANY_MERIDIEM_CHARACTER_RE, newMeridiem), selection: [caretIndex, caretIndex], }); }; element.addEventListener('keydown', listener); return () => element.removeEventListener('keydown', listener); }; } ================================================ FILE: projects/kit/src/lib/plugins/time/time-segments-stepping.ts ================================================ import {type MaskitoPlugin, maskitoUpdateElement} from '@maskito/core'; import type {MaskitoTimeSegments} from '../../types'; import {noop} from '../../utils'; export function createTimeSegmentsSteppingPlugin({ step, fullMode, timeSegmentMinValues, timeSegmentMaxValues, }: { step: number; fullMode: string; timeSegmentMinValues: MaskitoTimeSegments; timeSegmentMaxValues: MaskitoTimeSegments; }): MaskitoPlugin { const segmentsIndexes = createTimeSegmentsIndexes(fullMode); return step <= 0 ? noop : (element) => { const listener = (event: KeyboardEvent): void => { if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') { return; } event.preventDefault(); const selectionStart = element.selectionStart ?? 0; const activeSegment = getActiveSegment({ segmentsIndexes, selectionStart, }); if (!activeSegment) { return; } const updatedValue = updateSegmentValue({ selection: segmentsIndexes.get(activeSegment)!, value: element.value, toAdd: event.key === 'ArrowUp' ? step : -step, min: timeSegmentMinValues[activeSegment], max: timeSegmentMaxValues[activeSegment], }); maskitoUpdateElement(element, { value: updatedValue, selection: [selectionStart, selectionStart], }); }; element.addEventListener('keydown', listener); return () => element.removeEventListener('keydown', listener); }; } function createTimeSegmentsIndexes( fullMode: string, ): Map { return new Map([ ['hours', getSegmentRange(fullMode, 'HH')], ['milliseconds', getSegmentRange(fullMode, 'MSS')], ['minutes', getSegmentRange(fullMode, 'MM')], ['seconds', getSegmentRange(fullMode, 'SS')], ]); } function getSegmentRange(mode: string, segment: string): [number, number] { const index = mode.indexOf(segment); return index === -1 ? [-1, -1] : [index, index + segment.length]; } function getActiveSegment({ segmentsIndexes, selectionStart, }: { segmentsIndexes: Map; selectionStart: number; }): keyof MaskitoTimeSegments | null { for (const [segmentName, segmentRange] of segmentsIndexes.entries()) { const [from, to] = segmentRange; if (from <= selectionStart && selectionStart <= to) { return segmentName; } } return null; } function updateSegmentValue({ selection, value, toAdd, min, max, }: { selection: readonly [number, number]; value: string; toAdd: number; min: number; max: number; }): string { const [from, to] = selection; const segmentValue = Number(value.slice(from, to).padEnd(to - from, '0')); const newSegmentValue = mod(segmentValue + toAdd, min, max + 1); return `${value.slice(0, from)}${String(newSegmentValue).padStart(to - from, '0')}${value.slice(to, value.length)}`; } function mod(value: number, min: number, max: number): number { const range = max - min; return ((((value - min) % range) + range) % range) + min; } ================================================ FILE: projects/kit/src/lib/processors/colon-convert-preprocessor.ts ================================================ import type {MaskitoPreprocessor} from '@maskito/core'; import {toHalfWidthColon} from '../utils'; /** * Convert full width colon (:) to half width one (:) */ export function createColonConvertPreprocessor(): MaskitoPreprocessor { return ({elementState, data}) => { const {value, selection} = elementState; return { elementState: { selection, value: toHalfWidthColon(value), }, data: toHalfWidthColon(data), }; }; } ================================================ FILE: projects/kit/src/lib/processors/date-segments-zero-padding-postprocessor.ts ================================================ import type {MaskitoPostprocessor} from '@maskito/core'; import {DATE_SEGMENTS_MAX_VALUES} from '../constants'; import type {MaskitoDateSegments} from '../types'; import {padWithZeroesUntilValid, parseDateString, toDateString} from '../utils'; export function createDateSegmentsZeroPaddingPostprocessor({ dateModeTemplate, dateSegmentSeparator, splitFn, uniteFn, }: { dateModeTemplate: string; dateSegmentSeparator: string; splitFn: (value: string) => {dateStrings: string[]; restPart?: string}; uniteFn: (validatedDateStrings: string[], initialValue: string) => string; }): MaskitoPostprocessor { return ({value, selection}) => { const [from, to] = selection; const {dateStrings, restPart = ''} = splitFn(value); const validatedDateStrings: string[] = []; let caretShift = 0; dateStrings.forEach((dateString) => { const parsedDate = parseDateString(dateString, dateModeTemplate); const dateSegments = Object.entries(parsedDate) as Array< [keyof MaskitoDateSegments, string] >; const validatedDateSegments = dateSegments.reduce( (acc, [segmentName, segmentValue]) => { const {validatedSegmentValue, prefixedZeroesCount} = padWithZeroesUntilValid( segmentValue, `${DATE_SEGMENTS_MAX_VALUES[segmentName]}`, ); caretShift += prefixedZeroesCount; return {...acc, [segmentName]: validatedSegmentValue}; }, {}, ); validatedDateStrings.push( toDateString(validatedDateSegments, {dateMode: dateModeTemplate}), ); }); const validatedValue = `${uniteFn(validatedDateStrings, value)}${ dateStrings[dateStrings.length - 1]?.endsWith(dateSegmentSeparator) ? dateSegmentSeparator : '' }${restPart}`; if ( caretShift && validatedValue.slice( to + caretShift, to + caretShift + dateSegmentSeparator.length, ) === dateSegmentSeparator ) { /** * If `caretShift` > 0, it means that time segment was padded with zero. * It is only possible if any character insertion happens. * If caret is before `dateSegmentSeparator` => it should be moved after `dateSegmentSeparator`. */ caretShift += dateSegmentSeparator.length; } return { selection: [from + caretShift, to + caretShift], value: validatedValue, }; }; } ================================================ FILE: projects/kit/src/lib/processors/first-date-end-separator-preprocessor.ts ================================================ import type {MaskitoPreprocessor} from '@maskito/core'; import {getFirstCompleteDate} from '../utils'; /** * It replaces pseudo range separators with valid one. * @example '01.01.2000_11.11.2000' -> '01.01.2000 - 01.01.2000' * @example '01.01.2000_23:59' -> '01.01.2000, 23:59' */ export function createFirstDateEndSeparatorPreprocessor({ dateModeTemplate, firstDateEndSeparator, dateSegmentSeparator, pseudoFirstDateEndSeparators, }: { dateModeTemplate: string; firstDateEndSeparator: string; dateSegmentSeparator: string; pseudoFirstDateEndSeparators: string[]; }): MaskitoPreprocessor { return ({elementState, data}) => { const {value, selection} = elementState; const [from, to] = selection; const firstCompleteDate = getFirstCompleteDate(value, dateModeTemplate); const pseudoSeparators = pseudoFirstDateEndSeparators.filter( (x) => !firstDateEndSeparator.includes(x) && x !== dateSegmentSeparator, ); const pseudoSeparatorsRE = new RegExp(`[${pseudoSeparators.join('')}]`, 'gi'); const newValue = firstCompleteDate && value.length > firstCompleteDate.length ? `${firstCompleteDate}${value .slice(firstCompleteDate.length) .replace(/^\D*/, firstDateEndSeparator)}` : value; const caretShift = newValue.length - value.length; return { elementState: { selection: [from + caretShift, to + caretShift], value: newValue, }, data: data.replace(pseudoSeparatorsRE, firstDateEndSeparator), }; }; } ================================================ FILE: projects/kit/src/lib/processors/fullwidth-to-halfwidth-preprocessor.ts ================================================ import type {MaskitoPreprocessor} from '@maskito/core'; import {toHalfWidthNumber} from '../utils'; /** * Convert full width numbers like 1, 2 to half width numbers 1, 2 */ export function createFullWidthToHalfWidthPreprocessor(): MaskitoPreprocessor { return ({elementState, data}) => { const {value, selection} = elementState; return { elementState: { selection, value: toHalfWidthNumber(value), }, data: toHalfWidthNumber(data), }; }; } ================================================ FILE: projects/kit/src/lib/processors/index.ts ================================================ export {createColonConvertPreprocessor} from './colon-convert-preprocessor'; export {createDateSegmentsZeroPaddingPostprocessor} from './date-segments-zero-padding-postprocessor'; export {createFirstDateEndSeparatorPreprocessor} from './first-date-end-separator-preprocessor'; export {createFullWidthToHalfWidthPreprocessor} from './fullwidth-to-halfwidth-preprocessor'; export {createInvalidTimeSegmentInsertionPreprocessor} from './invalid-time-segment-insertion-preprocessor'; export { createMeridiemPostprocessor, createMeridiemPreprocessor, } from './meridiem-processors'; export {createMinMaxDatePostprocessor} from './min-max-date-postprocessor'; export {normalizeDatePreprocessor} from './normalize-date-preprocessor'; export {maskitoPostfixPostprocessorGenerator} from './postfix-postprocessor'; export {maskitoPrefixPostprocessorGenerator} from './prefix-postprocessor'; export {createValidDatePreprocessor} from './valid-date-preprocessor'; export {maskitoWithPlaceholder} from './with-placeholder'; export {createZeroPlaceholdersPreprocessor} from './zero-placeholders-preprocessor'; ================================================ FILE: projects/kit/src/lib/processors/invalid-time-segment-insertion-preprocessor.ts ================================================ import type {MaskitoPreprocessor} from '@maskito/core'; import { DEFAULT_TIME_SEGMENT_MAX_VALUES, DEFAULT_TIME_SEGMENT_MIN_VALUES, TIME_FIXED_CHARACTERS, TIME_SEGMENT_VALUE_LENGTHS, } from '../constants'; import type {MaskitoTimeMode, MaskitoTimeSegments} from '../types'; import {clamp, escapeRegExp} from '../utils'; import {parseTimeString} from '../utils/time'; /** * Prevent insertion if any time segment will become invalid * (and even zero padding won't help with it). * @example 2|0:00 => Type 9 => 2|0:00 */ export function createInvalidTimeSegmentInsertionPreprocessor({ timeMode, timeSegmentMinValues = DEFAULT_TIME_SEGMENT_MIN_VALUES, timeSegmentMaxValues = DEFAULT_TIME_SEGMENT_MAX_VALUES, parseValue = (x) => ({timeString: x}), }: { timeMode: MaskitoTimeMode; timeSegmentMinValues?: MaskitoTimeSegments; timeSegmentMaxValues?: MaskitoTimeSegments; parseValue?: (value: string) => {timeString: string; restValue?: string}; }): MaskitoPreprocessor { const invalidCharsRegExp = new RegExp( String.raw`[^\d${TIME_FIXED_CHARACTERS.map(escapeRegExp).join('')}]+`, ); return ({elementState, data}, actionType) => { if (actionType !== 'insert') { return {elementState, data}; } const {value, selection} = elementState; const [from, rawTo] = selection; const newCharacters = data.replace(invalidCharsRegExp, ''); const to = rawTo + newCharacters.length; // to be conformed with `overwriteMode: replace` const newPossibleValue = `${value.slice(0, from)}${newCharacters}${value.slice(to)}`; const {timeString, restValue = ''} = parseValue(newPossibleValue); const timeSegments = Object.entries( parseTimeString(timeString, timeMode), ) as Array<[keyof MaskitoTimeSegments, string]>; let offset = restValue.length; for (const [segmentName, stringifiedSegmentValue] of timeSegments) { const minSegmentValue = timeSegmentMinValues[segmentName]; const maxSegmentValue = timeSegmentMaxValues[segmentName]; const segmentValue = Number(stringifiedSegmentValue); const lastSegmentDigitIndex = offset + TIME_SEGMENT_VALUE_LENGTHS[segmentName]; if ( lastSegmentDigitIndex >= from && lastSegmentDigitIndex <= to && segmentValue !== clamp(segmentValue, minSegmentValue, maxSegmentValue) ) { return {elementState, data: ''}; // prevent insertion } offset += stringifiedSegmentValue.length + // any time segment separator 1; } return {elementState, data}; }; } ================================================ FILE: projects/kit/src/lib/processors/meridiem-processors.ts ================================================ import type {MaskitoPostprocessor, MaskitoPreprocessor} from '@maskito/core'; import { ALL_MERIDIEM_CHARACTERS_RE, ANY_MERIDIEM_CHARACTER_RE, CHAR_NO_BREAK_SPACE, } from '../constants'; import type {MaskitoTimeMode} from '../types'; import {identity} from '../utils'; export function createMeridiemPreprocessor( timeMode: MaskitoTimeMode, ): MaskitoPreprocessor { if (!timeMode.includes('AA')) { return identity; } const mainMeridiemCharRE = /^[AP]$/gi; return ({elementState, data}) => { const {value, selection} = elementState; const newValue = value.toUpperCase(); const newData = data.toUpperCase(); if ( newValue.match(ALL_MERIDIEM_CHARACTERS_RE) && newData.match(mainMeridiemCharRE) ) { return { elementState: { value: newValue.replaceAll(ALL_MERIDIEM_CHARACTERS_RE, ''), selection, }, data: `${newData}M`, }; } return {elementState: {selection, value: newValue}, data: newData}; }; } export function createMeridiemPostprocessor( timeMode: MaskitoTimeMode, ): MaskitoPostprocessor { if (!timeMode.includes('AA')) { return identity; } return ({value, selection}, initialElementState) => { if ( !value.match(ANY_MERIDIEM_CHARACTER_RE) || value.match(ALL_MERIDIEM_CHARACTERS_RE) ) { return {value, selection}; } const [from, to] = selection; // any meridiem character was deleted if (initialElementState.value.match(ALL_MERIDIEM_CHARACTERS_RE)) { const newValue = value.replace(ANY_MERIDIEM_CHARACTER_RE, ''); return { value: newValue, selection: [ Math.min(from, newValue.length), Math.min(to, newValue.length), ], }; } const fullMeridiem = `${CHAR_NO_BREAK_SPACE}${value.includes('P') ? 'P' : 'A'}M`; const newValue = value.replace(ANY_MERIDIEM_CHARACTER_RE, (x) => x === CHAR_NO_BREAK_SPACE ? x : fullMeridiem, ); return { value: newValue, selection: to >= newValue.indexOf(fullMeridiem) ? [newValue.length, newValue.length] : selection, }; }; } ================================================ FILE: projects/kit/src/lib/processors/min-max-date-postprocessor.ts ================================================ import type {MaskitoPostprocessor} from '@maskito/core'; import {DEFAULT_MAX_DATE, DEFAULT_MIN_DATE} from '../constants'; import { clamp, dateToSegments, isDateStringComplete, parseDateRangeString, parseDateString, segmentsToDate, toDateString, } from '../utils'; import {raiseSegmentValueToMin} from '../utils/date/raise-segment-value-to-min'; const LEAP_YEAR = '1972'; export function createMinMaxDatePostprocessor({ dateModeTemplate, min = DEFAULT_MIN_DATE, max = DEFAULT_MAX_DATE, rangeSeparator = '', dateSegmentSeparator = '.', }: { dateModeTemplate: string; min?: Date; max?: Date; rangeSeparator?: string; dateSegmentSeparator?: string; }): MaskitoPostprocessor { return ({value, selection}) => { const endsWithRangeSeparator = rangeSeparator && value.endsWith(rangeSeparator); const dateStrings = parseDateRangeString(value, dateModeTemplate, rangeSeparator); let validatedValue = ''; for (const dateString of dateStrings) { validatedValue += validatedValue ? rangeSeparator : ''; const parsedDate = parseDateString(dateString, dateModeTemplate); if (!isDateStringComplete(dateString, dateModeTemplate)) { const fixedDate = raiseSegmentValueToMin(parsedDate, dateModeTemplate); const fixedValue = toDateString(fixedDate, {dateMode: dateModeTemplate}); const tail = dateString.endsWith(dateSegmentSeparator) ? dateSegmentSeparator : ''; validatedValue += `${fixedValue}${tail}`; continue; } const date = segmentsToDate({year: LEAP_YEAR, ...parsedDate}); const clampedDate = clamp(date, min, max); validatedValue += toDateString(dateToSegments(clampedDate), { dateMode: dateModeTemplate, }); } return { selection, value: `${validatedValue}${endsWithRangeSeparator ? rangeSeparator : ''}`, }; }; } ================================================ FILE: projects/kit/src/lib/processors/normalize-date-preprocessor.ts ================================================ import type {MaskitoPreprocessor} from '@maskito/core'; import {DATE_TIME_SEPARATOR} from '../masks/date-time/constants'; export function normalizeDatePreprocessor({ dateModeTemplate, dateSegmentsSeparator, rangeSeparator = '', dateTimeSeparator = DATE_TIME_SEPARATOR, }: { dateModeTemplate: string; dateSegmentsSeparator: string; rangeSeparator?: string; dateTimeSeparator?: string; }): MaskitoPreprocessor { return ({elementState, data}) => { const templateSegments = dateModeTemplate.split(dateSegmentsSeparator); const includesTime = data.includes(dateTimeSeparator); const dateSegments = data .slice(0, includesTime ? data.indexOf(dateTimeSeparator) : Infinity) .split(/\D/) .filter(Boolean); if (!dateSegments.length || dateSegments.length % templateSegments.length !== 0) { return {elementState, data}; } const dates = dateSegments.reduce((dates, segment, index) => { const template = templateSegments[index % templateSegments.length] ?? ''; const dateIndex = Math.trunc(index / templateSegments.length); const isLastDateSegment = index % templateSegments.length === templateSegments.length - 1; if (!dates[dateIndex]) { dates[dateIndex] = ''; } dates[dateIndex] += isLastDateSegment ? segment : `${segment.padStart(template.length, '0')}${dateSegmentsSeparator}`; return dates; }, []); return { elementState, data: includesTime ? `${dates[0]}${data.slice(data.indexOf(dateTimeSeparator))}` : dates.join(rangeSeparator), }; }; } ================================================ FILE: projects/kit/src/lib/processors/postfix-postprocessor.ts ================================================ import type {MaskitoPostprocessor} from '@maskito/core'; import {escapeRegExp, findCommonBeginningSubstr, identity} from '../utils'; export function maskitoPostfixPostprocessorGenerator( postfix: string, ): MaskitoPostprocessor { const completedPostfixRE = new RegExp(`${escapeRegExp(postfix)}$`); const incompletePostfixRE = new RegExp( postfix && `(${postfix .split('') .map(escapeRegExp) // eslint-disable-next-line .reduce((acc, _, i, arr) => `${acc}|${arr.slice(0, i + 1).join('')}`)})$`, ); return postfix ? ({value, selection}, initialElementState) => { if (!value && !initialElementState.value.endsWith(postfix)) { // cases when developer wants input to be empty (programmatically) return {value, selection}; } if ( !value.match(incompletePostfixRE) && !initialElementState.value.endsWith(postfix) ) { return {selection, value: `${value}${postfix}`}; } const initialValueBeforePostfix = initialElementState.value.replace( completedPostfixRE, '', ); const postfixWasModified = initialElementState.selection[1] > initialValueBeforePostfix.length; const alreadyExistedValueBeforePostfix = findCommonBeginningSubstr( initialValueBeforePostfix, value, ); return { selection, value: Array.from(postfix) .reverse() .reduce((newValue, char, index) => { const i = newValue.length - 1 - index; const isInitiallyMirroredChar = alreadyExistedValueBeforePostfix[i] === char && postfixWasModified; return newValue[i] !== char || isInitiallyMirroredChar ? `${newValue.slice(0, i + 1)}${char}${newValue.slice(i + 1)}` : newValue; }, value), }; } : identity; } ================================================ FILE: projects/kit/src/lib/processors/prefix-postprocessor.ts ================================================ import type {MaskitoPostprocessor} from '@maskito/core'; import {identity} from '../utils'; export function maskitoPrefixPostprocessorGenerator( prefix: string, ): MaskitoPostprocessor { return prefix ? ({value, selection}, initialElementState) => { if ( value.startsWith(prefix) || // already valid (!value && !initialElementState.value.startsWith(prefix)) // cases when developer wants input to be empty ) { return {value, selection}; } const [from, to] = selection; const prefixedValue = Array.from(prefix).reduce( (modifiedValue, char, i) => modifiedValue[i] === char ? modifiedValue : `${modifiedValue.slice(0, i)}${char}${modifiedValue.slice(i)}`, value, ); const addedCharsCount = prefixedValue.length - value.length; return { selection: [from + addedCharsCount, to + addedCharsCount], value: prefixedValue, }; } : identity; } ================================================ FILE: projects/kit/src/lib/processors/tests/first-date-end-separator-preprocessor.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {POSSIBLE_DATE_RANGE_SEPARATOR} from '../../masks/date-range/constants'; import {POSSIBLE_DATE_TIME_SEPARATOR} from '../../masks/date-time/constants'; import {createFirstDateEndSeparatorPreprocessor} from '../first-date-end-separator-preprocessor'; describe('FirstDateEndSeparatorPreprocessor', () => { const EMPTY_SELECTION = [0, 0] as const; describe('DateRange', () => { const preprocessorFn = createFirstDateEndSeparatorPreprocessor({ dateModeTemplate: 'dd.mm.yyyy', firstDateEndSeparator: ' ~ ', dateSegmentSeparator: '.', pseudoFirstDateEndSeparators: POSSIBLE_DATE_RANGE_SEPARATOR, }); const preprocess = (value: string): string => preprocessorFn( {elementState: {value, selection: EMPTY_SELECTION}, data: ''}, 'validation', ).elementState.value; it('only complete date (without date end separator)', () => { expect(preprocess('01.01.2000')).toBe('01.01.2000'); }); it('only complete date + date end separator', () => { expect(preprocess('01.01.2000~')).toBe('01.01.2000 ~ '); }); it('01.01.2000_11.11.2011', () => { expect(preprocess('01.01.2000_11.11.2011')).toBe('01.01.2000 ~ 11.11.2011'); }); it('01.01.2000-11.11.2011', () => { expect(preprocess('01.01.2000-11.11.2011')).toBe('01.01.2000 ~ 11.11.2011'); }); it('01-01-2000 - 11-11-2011', () => { expect(preprocess('01-01-2000 - 11-11-2011')).toBe('01-01-2000 ~ 11-11-2011'); }); it('01.01.2000~11.11.2011', () => { expect(preprocess('01.01.2000~11.11.2011')).toBe('01.01.2000 ~ 11.11.2011'); }); it('01.01.2000 ~ 11.11.2011', () => { expect(preprocess('01.01.2000 ~ 11.11.2011')).toBe('01.01.2000 ~ 11.11.2011'); }); it('`value` contains only complete date and `data` contains pseudo range separator', () => { const {elementState, data} = preprocessorFn( { elementState: {value: '01.01.2000', selection: EMPTY_SELECTION}, data: '-', }, 'insert', ); expect(elementState.value).toBe('01.01.2000'); expect(data).toBe(' ~ '); }); }); describe('DateTime', () => { const preprocessorFn = createFirstDateEndSeparatorPreprocessor({ dateModeTemplate: 'dd.mm.yyyy', firstDateEndSeparator: '_', dateSegmentSeparator: '.', pseudoFirstDateEndSeparators: POSSIBLE_DATE_TIME_SEPARATOR, }); // eslint-disable-next-line sonarjs/no-identical-functions const preprocess = (value: string): string => preprocessorFn( {elementState: {value, selection: EMPTY_SELECTION}, data: ''}, 'validation', ).elementState.value; it('01.01.2000,23:59', () => { expect(preprocess('01.01.2000,23:59')).toBe('01.01.2000_23:59'); }); it('01.01.2000, 23:59', () => { expect(preprocess('01.01.2000, 23:59')).toBe('01.01.2000_23:59'); }); it('01.01.2000_23:59', () => { expect(preprocess('01.01.2000_23:59')).toBe('01.01.2000_23:59'); }); it('01-01-2000-23:59', () => { expect(preprocess('01-01-2000-23:59')).toBe('01-01-2000_23:59'); }); }); }); ================================================ FILE: projects/kit/src/lib/processors/tests/normalize-date-preprocessor.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import type {MaskitoPreprocessor} from '@maskito/core'; import {normalizeDatePreprocessor} from '../normalize-date-preprocessor'; describe('normalizeDatePreprocessor', () => { describe('Input-date-range', () => { const preprocessor = normalizeDatePreprocessor({ dateModeTemplate: 'dd.mm.yyyy', dateSegmentsSeparator: '.', rangeSeparator: ' – ', }); const check = getCheckFunction(preprocessor); it('empty input => 6.2.2023 – 7.2.2023', () => { check('6.2.2023 – 7.2.2023', '06.02.2023 – 07.02.2023'); }); it('empty input => 6.2.2023 – 7.2.2023 (basic spaces)', () => { check('6.2.2023 – 7.2.2023', '06.02.2023 – 07.02.2023'); }); it('empty input => 06.2.2023-07.2.2023', () => { check('06.2.2023-07.2.2023', '06.02.2023 – 07.02.2023'); }); it('empty input => 06-2-2023 - 07-2-2023', () => { check('06-2-2023-07-2-2023', '06.02.2023 – 07.02.2023'); }); it('empty input => 06-2-2023-07-2-2023', () => { check('06-2-2023-07-2-2023', '06.02.2023 – 07.02.2023'); }); }); describe('Input-date long mode', () => { const preprocessor = normalizeDatePreprocessor({ dateModeTemplate: 'dd.mm.yyyy', dateSegmentsSeparator: '.', }); const check = getCheckFunction(preprocessor); it('empty input => 6.2.2023', () => { check('6.2.2023', '06.02.2023'); }); it('empty input => 06.2.2023', () => { check('06.2.2023', '06.02.2023'); }); it('empty input => 06.2.20', () => { check('06.2.20', '06.02.20'); }); }); describe('input-date short mode', () => { const preprocessor = normalizeDatePreprocessor({ dateModeTemplate: 'mm/yy', dateSegmentsSeparator: '/', }); const check = getCheckFunction(preprocessor); it('empty input => 2/2/22', () => { check('2/2', '02/2'); }); it('empty input => 1.1', () => { check('1.1', '01/1'); }); it('empty input => 3.12', () => { check('3.12', '03/12'); }); }); describe('input-date-time', () => { const preprocessor = normalizeDatePreprocessor({ dateModeTemplate: 'dd.mm.yyyy', dateSegmentsSeparator: '.', }); const check = getCheckFunction(preprocessor); it('empty input => 6.2.2023, 12:00', () => { check('6.2.2023, 12:00', '06.02.2023, 12:00'); }); it('empty input => 6.2.2023, 15', () => { check('6.2.2023, 15', '06.02.2023, 15'); }); it('empty input => 06.2.2023', () => { check('06.2.2023', '06.02.2023'); }); it('empty input => 6.2.2023', () => { check('6.2.2022, 15', '06.02.2022, 15'); }); it('empty input => 6.2.2023, 12:01.001', () => { check('6.2.2023, 12:01.001', '06.02.2023, 12:01.001'); }); it('empty input => 6.2.2023, 01.001', () => { check('6.2.2023, 01.001', '06.02.2023, 01.001'); }); }); }); function getCheckFunction( preprocessor: MaskitoPreprocessor, ): (actual: string, expected: string) => void { return (insertedCharacters: string, expectedValue: string): void => { const EMPTY_INPUT = {value: '', selection: [0, 0] as [number, number]}; const {data} = preprocessor( {elementState: EMPTY_INPUT, data: insertedCharacters}, 'insert', ); expect(data).toEqual(expectedValue); }; } ================================================ FILE: projects/kit/src/lib/processors/tests/postfix-postprocessor.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {maskitoPostfixPostprocessorGenerator} from '../postfix-postprocessor'; describe('maskitoPostfixPostprocessorGenerator', () => { const EMPTY_INPUT = {value: '', selection: [0, 0] as const}; describe('postfix is a single character', () => { const postprocessor = maskitoPostfixPostprocessorGenerator('%'); it('does not add postfix if input was initially empty', () => { expect(postprocessor(EMPTY_INPUT, EMPTY_INPUT)).toEqual(EMPTY_INPUT); }); it('type 99 => 99%', () => { expect( postprocessor( {value: '99', selection: [2, 2]}, // after changes // percent sign was deleted by backspace {value: '99%', selection: [3, 3]}, // before changes (initialElementState) ), ).toEqual({value: '99%', selection: [2, 2]}); }); it('paste 99% => 99% (no extra percent sign)', () => { expect( postprocessor( {value: '99%', selection: [3, 3]}, // after // paste from clipboard EMPTY_INPUT, // before ), ).toEqual({value: '99%', selection: [3, 3]}); }); }); describe('postfix consists of many characters', () => { describe('postfix=.00', () => { const postprocessor = maskitoPostfixPostprocessorGenerator('.00'); it('does not add postfix if input was initially empty', () => { expect(postprocessor(EMPTY_INPUT, EMPTY_INPUT)).toEqual(EMPTY_INPUT); }); it('type 100 => 100.00', () => { expect( postprocessor( {value: '100', selection: [3, 3]}, // after EMPTY_INPUT, // before ), ).toEqual({value: '100.00', selection: [3, 3]}); }); it('100.0 => 100.00', () => { expect( postprocessor( {value: '100.0', selection: [5, 5]}, // after // attempt to delete character from postfix {value: '100.00', selection: [6, 6]}, // before ), ).toEqual({value: '100.00', selection: [5, 5]}); }); it('100. => 100.00', () => { expect( postprocessor( {value: '100.', selection: [4, 4]}, // after // attempt to delete many characters from postfix {value: '100.00', selection: [6, 6]}, // before ), ).toEqual({value: '100.00', selection: [4, 4]}); }); }); describe('postfix=_lbs_per_day', () => { const postprocessor = maskitoPostfixPostprocessorGenerator('_lbs_per_day'); it('paste 100 + partially filled postfix => 100_lbs_per_day', () => { expect( postprocessor( { value: '100_lbs', selection: ['100_lbs'.length, '100_lbs'.length], }, EMPTY_INPUT, ), ).toEqual({ value: '100_lbs_per_day', selection: ['100_lbs'.length, '100_lbs'.length], }); }); }); }); describe('postfix starts with the same character as other part of the value ends', () => { it('$_100_per_kg => $_|100_|per_kg (select all digits and underscore) => Delete => $_|_per_kg', () => { const postprocessor = maskitoPostfixPostprocessorGenerator('_per_kg'); expect( postprocessor( {value: '$_per_kg', selection: [2, 2]}, // after {value: '$_100_per_kg', selection: ['$_'.length, '$_100_'.length]}, // initial ), ).toEqual({value: '$__per_kg', selection: [2, 2]}); }); it('$__100__per_kg => $__|100__|per_kg (select all digits and 2 underscore) => Delete => $__|__per_kg', () => { const postprocessor = maskitoPostfixPostprocessorGenerator('__per_kg'); expect( postprocessor( {value: '$__per_kg', selection: [3, 3]}, // after { value: '$__100__per_kg', selection: ['$__'.length, '$__100__'.length], }, // initial ), ).toEqual({value: '$____per_kg', selection: [3, 3]}); }); }); }); ================================================ FILE: projects/kit/src/lib/processors/tests/prefix-postprocessor.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import type {MaskitoPostprocessor} from '@maskito/core'; import {maskitoPrefixPostprocessorGenerator} from '../prefix-postprocessor'; type ElementState = ReturnType; describe('maskitoPrefixPostprocessorGenerator', () => { const EMPTY_INPUT = {value: '', selection: [0, 0] as const}; describe('prefix is a single character', () => { const postprocessor = maskitoPrefixPostprocessorGenerator('$'); it('does not add prefix if input was initially empty', () => { expect(postprocessor(EMPTY_INPUT, EMPTY_INPUT)).toEqual(EMPTY_INPUT); }); it('123 => $|123', () => { expect( postprocessor( {value: '123', selection: [0, 0]}, // after changes // percent sign was deleted by backspace {value: '$123', selection: [1, 1]}, // before changes ), ).toEqual({value: '$123', selection: [1, 1]}); }); it('paste $123 => $123 (no extra dollar sign)', () => { expect( postprocessor( {value: '$123', selection: [4, 4]}, // after // // paste from clipboard EMPTY_INPUT, // before ), ).toEqual({value: '$123', selection: [4, 4]}); }); }); describe('prefix consists of many characters', () => { const postprocessor = maskitoPrefixPostprocessorGenerator('kg '); it('does not add prefix if input was initially empty', () => { expect(postprocessor(EMPTY_INPUT, EMPTY_INPUT)).toEqual(EMPTY_INPUT); }); it('123 => kg |123', () => { expect( postprocessor( {value: '123', selection: [0, 0]}, // after // all characters from prefix was deleted {value: 'kg 123', selection: [3, 3]}, // before ), ).toEqual({value: 'kg 123', selection: [3, 3]}); }); it('g 123 => kg |123', () => { expect( postprocessor( {value: 'g 123', selection: [0, 0]}, // after // leading character from prefix was deleted {value: 'kg 123', selection: [1, 1]}, // before ), ).toEqual({value: 'kg 123', selection: [1, 1]}); }); it(' 123 => kg |123', () => { expect( postprocessor( {value: ' 123', selection: [0, 0]}, // after // some characters from prefix was deleted {value: 'kg 123', selection: [2, 2]}, // before ), ).toEqual({value: 'kg 123', selection: [2, 2]}); }); describe('Textfield is empty => Type any character from prefix', () => { const process = (elementState: ElementState): ElementState => postprocessor(elementState, EMPTY_INPUT); it('empty input => type k (part of prefix) => kg |', () => { expect(process({value: 'k', selection: [1, 1]})).toEqual({ value: 'kg ', selection: [3, 3], }); }); it('empty input => type g (part of prefix) => kg |', () => { expect(process({value: 'g', selection: [1, 1]})).toEqual({ value: 'kg ', selection: [3, 3], }); }); it('empty input => type space (part of prefix) => kg |', () => { expect(process({value: ' ', selection: [1, 1]})).toEqual({ value: 'kg ', selection: [3, 3], }); }); }); }); }); ================================================ FILE: projects/kit/src/lib/processors/tests/valid-date-preprocessor.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {createValidDatePreprocessor} from '../valid-date-preprocessor'; describe('createValidDatePreprocessor', () => { describe('Paste/Drop of many characters', () => { const preprocessor = createValidDatePreprocessor({ dateModeTemplate: 'dd.mm.yyyy', dateSegmentsSeparator: '.', rangeSeparator: ' – ', }); const EMPTY_INPUT = {value: '', selection: [0, 0] as [number, number]}; const check = (insertedCharacters: string, expectedValue: string): void => { const {data} = preprocessor( {elementState: EMPTY_INPUT, data: insertedCharacters}, 'insert', ); expect(data).toEqual(expectedValue); }; it('empty input => 06.02.2023 – 07.02.2023 (non-breaking spaces)', () => { check('06.02.2023 – 07.02.2023', '06.02.2023 – 07.02.2023'); }); it('empty input => 06.02.2023 – 07.02.2023 (basic spaces)', () => { check('06.02.2023 – 07.02.2023', '06.02.202307.02.2023'); }); it('empty input => 06.02.2023–07.02.2023', () => { check('06.02.2023–07.02.2023', '06.02.202307.02.2023'); }); it('empty input => 06.02.202307.02.2023', () => { check('06.02.202307.02.2023', '06.02.202307.02.2023'); }); it('empty input => 0602202307022023', () => { check('0602202307022023', '06.02.202307.02.2023'); }); }); it('ignores range separator', () => { const rangeSeparator = ' – '; const processor = createValidDatePreprocessor({ rangeSeparator, dateModeTemplate: 'dd.mm.yyyy', dateSegmentsSeparator: '.', }); const initialState = { value: '06.02.2023', selection: ['06.02.2023'.length, '06.02.2023'.length] as const, }; const {elementState, data} = processor( {elementState: initialState, data: rangeSeparator}, 'insert', ); expect(elementState).toEqual(initialState); expect(data).toBe(rangeSeparator); }); }); ================================================ FILE: projects/kit/src/lib/processors/tests/with-placeholder.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {maskitoPipe} from '@maskito/core'; import {maskitoWithPlaceholder} from '@maskito/kit'; describe('maskitoWithPlaceholder("dd/mm/yyyy")', () => { const {preprocessors, postprocessors} = maskitoWithPlaceholder('dd/mm/yyyy'); const preprocessor = maskitoPipe(preprocessors); const postprocessor = maskitoPipe(postprocessors); const EMPTY_ELEMENT_STATE = { value: '', selection: [0, 0] as const, }; describe('preprocessors', () => { const check = (valueBefore: string, valueAfter: string): void => { const {elementState} = preprocessor( { elementState: { value: valueBefore, selection: [0, 0] as const, }, data: '', }, 'insert', ); expect(elementState.value).toBe(valueAfter); }; it('empty', () => check('', '')); it('2/mm/yyyy => 2', () => check('2d/mm/yyyy', '2')); it('26/mm/yyyy => 26', () => check('26/mm/yyyy', '26')); it('26/0m/yyyy => 26/0', () => check('26/0m/yyyy', '26/0')); it('26/01/yyyy => 26/01', () => check('26/01/yyyy', '26/01')); it('26/01/1yyy => 26/01/1', () => check('26/01/1yyy', '26/01/1')); it('26/01/19yy => 26/01/19', () => check('26/01/19yy', '26/01/19')); it('26/01/199y => 26/01/199', () => check('26/01/199y', '26/01/199')); it('26/01/1997 => 26/01/1997', () => check('26/01/1997', '26/01/1997')); }); describe('postprocessors', () => { beforeEach(() => { // Reset side effects from other tests preprocessor({elementState: EMPTY_ELEMENT_STATE, data: ''}, 'validation'); }); describe('different initial element state (2nd argument of postprocessor)', () => { const ONLY_PLACEHOLDER_STATE = { value: 'dd/mm/yyyy', selection: [0, 0] as const, }; [EMPTY_ELEMENT_STATE, ONLY_PLACEHOLDER_STATE].forEach((initialState) => { const check = (valueBefore: string, valueAfter: string): void => { const {value} = postprocessor( { value: valueBefore, selection: [0, 0] as const, }, initialState, ); expect(value).toBe(valueAfter); }; describe(`Initial element value is "${initialState.value}"`, () => { it('1 => 1d/mm/yyyy', () => check('1', '1d/mm/yyyy')); it('16 => 16/mm/yyyy', () => check('16', '16/mm/yyyy')); it('16/0 => 16/0m/yyyy', () => check('16/0', '16/0m/yyyy')); it('16/05 => 16/05/yyyy', () => check('16/05', '16/05/yyyy')); it('16/05/2 => 16/05/2yyy', () => check('16/05/2', '16/05/2yyy')); it('16/05/20 => 16/05/20yy', () => check('16/05/20', '16/05/20yy')); it('16/05/202 => 16/05/202y', () => check('16/05/202', '16/05/202y')); it('16/05/2023 => 16/05/2023', () => check('16/05/2023', '16/05/2023')); }); }); describe('postprocessor gets empty value', () => { /** * We can get this case only if textfield is updated programmatically. * User can't erase symbols from already empty textfield. */ it('if initial state has empty value too => Empty', () => { const {value} = postprocessor( { value: '', selection: [0, 0] as const, }, EMPTY_ELEMENT_STATE, ); expect(value).toBe(''); }); it('initial value is not empty => placeholder', () => { const {value} = postprocessor( { value: '', selection: [0, 0] as const, }, ONLY_PLACEHOLDER_STATE, ); expect(value).toBe('dd/mm/yyyy'); }); }); }); }); }); ================================================ FILE: projects/kit/src/lib/processors/valid-date-preprocessor.ts ================================================ import type {MaskitoPreprocessor} from '@maskito/core'; import {escapeRegExp, parseDateRangeString, validateDateString} from '../utils'; export function createValidDatePreprocessor({ dateModeTemplate, dateSegmentsSeparator, rangeSeparator = '', }: { dateModeTemplate: string; dateSegmentsSeparator: string; rangeSeparator?: string; }): MaskitoPreprocessor { return ({elementState, data}) => { const {value, selection} = elementState; if (data === dateSegmentsSeparator) { return { elementState, data: selection[0] === value.length ? data : '', }; } if (!data.replaceAll(/\D/g, '')) { return {elementState, data}; } const newCharacters = data.replaceAll( new RegExp( String.raw`[^\d${escapeRegExp(dateSegmentsSeparator)}${rangeSeparator}]`, 'g', ), '', ); const [from, rawTo] = selection; let to = rawTo + data.length; const newPossibleValue = `${value.slice(0, from)}${newCharacters}${value.slice(to)}`; const dateStrings = parseDateRangeString( newPossibleValue, dateModeTemplate, rangeSeparator, ); let validatedValue = ''; const hasRangeSeparator = Boolean(rangeSeparator) && newPossibleValue.includes(rangeSeparator); for (const dateString of dateStrings) { const {validatedDateString, updatedSelection} = validateDateString({ dateString, dateModeTemplate, dateSegmentsSeparator, offset: validatedValue.length, selection: [from, to], }); if (dateString && !validatedDateString) { return {elementState, data: ''}; // prevent changes } to = updatedSelection[1]; validatedValue += hasRangeSeparator && !validatedValue ? `${validatedDateString}${rangeSeparator}` : validatedDateString; } const newData = validatedValue.slice(from, to); return { elementState: { selection, value: `${validatedValue.slice(0, from)}${newData .split(dateSegmentsSeparator) .map((segment) => '0'.repeat(segment.length)) .join(dateSegmentsSeparator)}${validatedValue.slice(to)}`, }, data: newData, }; }; } ================================================ FILE: projects/kit/src/lib/processors/with-placeholder.ts ================================================ import { type MaskitoOptions, type MaskitoPreprocessor, maskitoUpdateElement, } from '@maskito/core'; import {maskitoCaretGuard, maskitoEventHandler} from '../plugins'; export function maskitoWithPlaceholder( placeholder: string, focusedOnly = false, ): Pick, 'plugins' | 'postprocessors' | 'preprocessors'> & { removePlaceholder: (value: string) => string; } { let lastClearValue = ''; let action: Parameters[1] = 'validation'; const removePlaceholder = (value: string): string => { for (let i = value.length - 1; i >= lastClearValue.length; i--) { if (value[i] !== placeholder[i]) { return value.slice(0, i + 1); } } return value.slice(0, lastClearValue.length); }; const plugins = [maskitoCaretGuard((value) => [0, removePlaceholder(value).length])]; let focused = false; if (focusedOnly) { const focus = maskitoEventHandler( 'focus', (element) => { focused = true; maskitoUpdateElement( element, `${element.value}${placeholder.slice(element.value.length)}`, ); }, {capture: true}, ); const blur = maskitoEventHandler( 'blur', (element) => { focused = false; maskitoUpdateElement(element, removePlaceholder(element.value)); }, {capture: true}, ); plugins.push(focus, blur); } return { plugins, removePlaceholder, preprocessors: [ ({elementState, data}, actionType) => { action = actionType; const {value, selection} = elementState; return { elementState: { selection, value: removePlaceholder(value), }, data, }; }, ], postprocessors: [ ({value, selection}, initialElementState) => { lastClearValue = value; const justPlaceholderRemoval = `${value}${placeholder.slice( value.length, initialElementState.value.length, )}` === initialElementState.value; if (action === 'validation' && justPlaceholderRemoval) { /** * If `value` still equals to `initialElementState.value`, * then it means that value is patched programmatically (from Maskito's plugin or externally). * In this case, we don't want to mutate value and automatically add/remove placeholder. * ___ * For example, developer wants to remove manually placeholder (+ do something else with value) on blur. * Without this condition, placeholder will be unexpectedly added again. */ return {selection, value: initialElementState.value}; } const newValue = focused || !focusedOnly ? `${value}${placeholder.slice(value.length)}` : value; if ( newValue === initialElementState.value && action === 'deleteBackward' ) { const [caretIndex] = initialElementState.selection; return { value: newValue, selection: [caretIndex, caretIndex], }; } return {value: newValue, selection}; }, ], }; } ================================================ FILE: projects/kit/src/lib/processors/zero-placeholders-preprocessor.ts ================================================ import type {MaskitoPreprocessor} from '@maskito/core'; export function createZeroPlaceholdersPreprocessor(postfix = ''): MaskitoPreprocessor { const isLastChar = (value: string, [_, to]: readonly [number, number]): boolean => to >= value.length - postfix.length; return ({elementState}, actionType) => { const {value, selection} = elementState; if (!value || isLastChar(value, selection)) { return {elementState}; } const [from, to] = selection; const zeroes = value.slice(from, to).replaceAll(/\d/g, '0'); const newValue = `${value.slice(0, from)}${zeroes}${value.slice(to)}`; if (!zeroes.replaceAll(/\D/g, '')) { return {elementState}; } if (actionType === 'validation' || (actionType === 'insert' && from === to)) { return {elementState: {selection, value: newValue}}; } return { elementState: { selection: actionType === 'deleteBackward' || actionType === 'insert' ? [from, from] : [to, to], value: newValue, }, }; }; } ================================================ FILE: projects/kit/src/lib/types/date-mode.ts ================================================ export type MaskitoDateMode = | 'dd/mm' | 'dd/mm/yyyy' | 'mm/dd' | 'mm/dd/yyyy' | 'mm/yy' | 'mm/yyyy' | 'yyyy' | 'yyyy/mm' | 'yyyy/mm/dd'; ================================================ FILE: projects/kit/src/lib/types/date-segments.ts ================================================ export interface MaskitoDateSegments { day: T; month: T; year: T; } ================================================ FILE: projects/kit/src/lib/types/index.ts ================================================ export * from './date-mode'; export * from './date-segments'; export * from './time-mode'; export * from './time-segments'; ================================================ FILE: projects/kit/src/lib/types/time-mode.ts ================================================ export type MaskitoTimeMode = | 'HH AA' | 'HH:MM AA' | 'HH:MM:SS AA' | 'HH:MM:SS.MSS AA' | 'HH:MM:SS.MSS' | 'HH:MM:SS' | 'HH:MM' | 'HH' | 'MM:SS.MSS' | 'MM:SS' | 'SS.MSS'; ================================================ FILE: projects/kit/src/lib/types/time-segments.ts ================================================ export interface MaskitoTimeSegments { hours: T; minutes: T; seconds: T; milliseconds: T; } ================================================ FILE: projects/kit/src/lib/utils/clamp.ts ================================================ /** * Clamps a value between two inclusive limits */ export function clamp( value: T, minimum: T | null, maximum?: T | null, ): T { const minClamped = max(minimum ?? value, value); return min(maximum ?? minClamped, minClamped); } function min(x: T, ...values: T[]): T { return values.reduce((a, b) => (a < b ? a : b), x); } function max(x: T, ...values: T[]): T { return values.reduce((a, b) => (a > b ? a : b), x); } ================================================ FILE: projects/kit/src/lib/utils/count-digits.ts ================================================ export function countDigits(str: string): number { return str.replaceAll(/\W/g, '').length; } ================================================ FILE: projects/kit/src/lib/utils/date/append-date.ts ================================================ import {MIN_DAY, MonthNumber, MONTHS_IN_YEAR} from '../../masks/date-range/constants'; import type {MaskitoDateSegments} from '../../types'; export function appendDate( date: Date, {day = 0, month = 0, year = 0}: Partial> = {}, ): Date { if (day === 0 && month === 0 && year === 0) { return date; } const initialYear = date.getFullYear(); const initialMonth = date.getMonth(); const initialDate = date.getDate(); const totalMonths = (initialYear + year) * MONTHS_IN_YEAR + initialMonth + month; let years = Math.floor(totalMonths / MONTHS_IN_YEAR); let months = totalMonths % MONTHS_IN_YEAR; const monthDaysCount = getMonthDaysCount(months, isLeapYear(years)); const currentMonthDaysCount = getMonthDaysCount(initialMonth, isLeapYear(years)); let days = day; if (initialDate >= monthDaysCount) { days += initialDate - (currentMonthDaysCount - monthDaysCount); } else if ( currentMonthDaysCount < monthDaysCount && initialDate === currentMonthDaysCount ) { days += initialDate + (monthDaysCount - currentMonthDaysCount); } else { days += initialDate; } while (days > getMonthDaysCount(months, isLeapYear(years))) { days -= getMonthDaysCount(months, isLeapYear(years)); if (months === MonthNumber.December) { years++; months = MonthNumber.January; } else { months++; } } while (days < MIN_DAY) { if (months === MonthNumber.January) { years--; months = MonthNumber.December; } else { months--; } days += getMonthDaysCount(months, isLeapYear(years)); } days = day < 0 || month < 0 || year < 0 ? days + 1 // add one day when moving days, or months, or years backward : days - 1; // "from"-day is included in the range return new Date(years, months, days); } function getMonthDaysCount(month: number, isLeapYear: boolean): number { switch (month) { case MonthNumber.April: case MonthNumber.June: case MonthNumber.November: case MonthNumber.September: return 30; case MonthNumber.February: return isLeapYear ? 29 : 28; default: return 31; } } function isLeapYear(year: number): boolean { return year % 400 === 0 || (year % 4 === 0 && year % 100 !== 0); } ================================================ FILE: projects/kit/src/lib/utils/date/date-segment-value-length.ts ================================================ import type {MaskitoDateSegments} from '../../types'; export const getDateSegmentValueLength: ( dateString: string, ) => MaskitoDateSegments = (dateString: string) => ({ day: dateString.match(/d/g)?.length ?? 0, month: dateString.match(/m/g)?.length ?? 0, year: dateString.match(/y/g)?.length ?? 0, }); ================================================ FILE: projects/kit/src/lib/utils/date/date-to-segments.ts ================================================ import type {MaskitoDateSegments, MaskitoTimeSegments} from '../../types'; export function dateToSegments(date: Date): MaskitoDateSegments & MaskitoTimeSegments { return { day: String(date.getDate()).padStart(2, '0'), month: String(date.getMonth() + 1).padStart(2, '0'), year: String(date.getFullYear()).padStart(4, '0'), hours: String(date.getHours()).padStart(2, '0'), minutes: String(date.getMinutes()).padStart(2, '0'), seconds: String(date.getSeconds()).padStart(2, '0'), milliseconds: String(date.getMilliseconds()).padStart(3, '0'), }; } ================================================ FILE: projects/kit/src/lib/utils/date/get-date-segments-order.ts ================================================ import type {MaskitoDateSegments} from '../../types'; const ALL_POSSIBLE_SEGMENTS: ReadonlyArray = [ 'day', 'month', 'year', ]; export function getDateSegmentsOrder( template: string, ): ReadonlyArray { return [...ALL_POSSIBLE_SEGMENTS].sort((a, b) => template.indexOf(a[0]!) > template.indexOf(b[0]!) ? 1 : -1, ); } ================================================ FILE: projects/kit/src/lib/utils/date/get-first-complete-date.ts ================================================ import {countDigits} from '../count-digits'; export function getFirstCompleteDate( dateString: string, dateModeTemplate: string, ): string { const digitsInDate = countDigits(dateModeTemplate); const [completeDate = ''] = new RegExp(String.raw`(\D*\d){${digitsInDate}}`).exec(dateString) || []; return completeDate; } ================================================ FILE: projects/kit/src/lib/utils/date/is-date-string-complete.ts ================================================ export function isDateStringComplete( dateString: string, dateModeTemplate: string, ): boolean { if (dateString.length < dateModeTemplate.length) { return false; } return dateString.split(/\D/).every((segment) => !/^0+$/.exec(segment)); } ================================================ FILE: projects/kit/src/lib/utils/date/parse-date-range-string.ts ================================================ import {countDigits} from '../count-digits'; export function parseDateRangeString( dateRange: string, dateModeTemplate: string, rangeSeparator: string, ): string[] { const digitsInDate = countDigits(dateModeTemplate); return ( dateRange .replace(rangeSeparator, '') .match(new RegExp(String.raw`(\D*\d[^\d\s]*){1,${digitsInDate}}`, 'g')) || [] ); } ================================================ FILE: projects/kit/src/lib/utils/date/parse-date-string.ts ================================================ import type {MaskitoDateSegments} from '../../types'; export function parseDateString( dateString: string, fullMode: string, ): Partial { const cleanMode = fullMode.replaceAll(/[^dmy]/g, ''); const onlyDigitsDate = dateString.replaceAll(/\D+/g, ''); const dateSegments: MaskitoDateSegments = { day: onlyDigitsDate.slice(cleanMode.indexOf('d'), cleanMode.lastIndexOf('d') + 1), month: onlyDigitsDate.slice( cleanMode.indexOf('m'), cleanMode.lastIndexOf('m') + 1, ), year: onlyDigitsDate.slice( cleanMode.indexOf('y'), cleanMode.lastIndexOf('y') + 1, ), }; return Object.fromEntries( Object.entries(dateSegments) .filter(([_, value]) => Boolean(value)) .sort(([a], [b]) => fullMode.toLowerCase().indexOf(a.slice(0, 1)) > fullMode.toLowerCase().indexOf(b.slice(0, 1)) ? 1 : -1, ), ); } ================================================ FILE: projects/kit/src/lib/utils/date/raise-segment-value-to-min.ts ================================================ import type {MaskitoDateSegments} from '../../types'; import {getDateSegmentValueLength} from './date-segment-value-length'; export function raiseSegmentValueToMin( segments: Partial, fullMode: string, ): Partial { const segmentsLength = getDateSegmentValueLength(fullMode); return Object.fromEntries( Object.entries(segments).map(([key, value]: [string, string]) => { const segmentLength = segmentsLength[key as keyof Partial]; return [ key, value.length === segmentLength && /^0+$/.exec(value) ? '1'.padStart(segmentLength, '0') : value, ]; }), ); } ================================================ FILE: projects/kit/src/lib/utils/date/segments-to-date.ts ================================================ import type {MaskitoDateSegments, MaskitoTimeSegments} from '../../types'; export function segmentsToDate( parsedDate: Partial, parsedTime?: Partial, ): Date { const year = parsedDate.year?.length === 2 ? `20${parsedDate.year}` : parsedDate.year; const date = new Date( Number(year ?? '0'), Number(parsedDate.month ?? '1') - 1, Number(parsedDate.day ?? '1'), Number(parsedTime?.hours ?? '0'), Number(parsedTime?.minutes ?? '0'), Number(parsedTime?.seconds ?? '0'), Number(parsedTime?.milliseconds ?? '0'), ); // needed for years less than 1900 date.setFullYear(Number(year ?? '0')); return date; } ================================================ FILE: projects/kit/src/lib/utils/date/tests/append-date.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {appendDate} from '../append-date'; const y2000m6d15 = new Date(2000, 6, 15); describe('appendDate', () => { it('year: 2000, month: 6, day: 15, if {} was passed', () => { const result = appendDate(y2000m6d15, {}); expect(result.getFullYear()).toBe(2000); expect(result.getMonth()).toBe(6); expect(result.getDate()).toBe(15); }); it('year: 2000, month: 6, day: 15, if {year: 0} was passed', () => { const result = appendDate(y2000m6d15, {year: 0}); expect(result.getFullYear()).toBe(2000); expect(result.getMonth()).toBe(6); expect(result.getDate()).toBe(15); }); it('year: 2000, month: 6, day: 15, if {year: 0, month: 0} was passed', () => { const result = appendDate(y2000m6d15, {year: 0, month: 0}); expect(result.getFullYear()).toBe(2000); expect(result.getMonth()).toBe(6); expect(result.getDate()).toBe(15); }); it('year: 2000, month: 6, day: 15, if {year: 0, month: 0, day: 0} was passed', () => { const result = appendDate(y2000m6d15, { year: 0, month: 0, day: 0, }); expect(result.getFullYear()).toBe(2000); expect(result.getMonth()).toBe(6); expect(result.getDate()).toBe(15); }); it('year: 2005, month: 6, day: 14, if {year: 5} was passed', () => { const result = appendDate(y2000m6d15, {year: 5}); expect(result.getFullYear()).toBe(2005); expect(result.getMonth()).toBe(6); expect(result.getDate()).toBe(14); }); it('year: 1995, month: 6, day: 16, if {year: -5} was passed', () => { const result = appendDate(y2000m6d15, {year: -5}); expect(result.getFullYear()).toBe(1995); expect(result.getMonth()).toBe(6); expect(result.getDate()).toBe(16); }); it('year: 2000, month: 11, day: 14, if {month: 5} was passed', () => { const result = appendDate(y2000m6d15, {month: 5}); expect(result.getFullYear()).toBe(2000); expect(result.getMonth()).toBe(11); expect(result.getDate()).toBe(14); }); it('year: 2000, month: 1, day: 16, if {month: -5} was passed', () => { const result = appendDate(y2000m6d15, {month: -5}); expect(result.getFullYear()).toBe(2000); expect(result.getMonth()).toBe(1); expect(result.getDate()).toBe(16); }); it('year: 2000, month: 6, day: 19, if {day: 5} was passed', () => { const result = appendDate(y2000m6d15, {day: 5}); expect(result.getFullYear()).toBe(2000); expect(result.getMonth()).toBe(6); expect(result.getDate()).toBe(19); }); it('year: 2000, month: 6, day: 11, if {day: -5} was passed', () => { const result = appendDate(y2000m6d15, {day: -5}); expect(result.getFullYear()).toBe(2000); expect(result.getMonth()).toBe(6); expect(result.getDate()).toBe(11); }); it('year: 2000, month: 6, day: 31, if {day: 17} was passed', () => { const result = appendDate(y2000m6d15, {day: 17}); expect(result.getFullYear()).toBe(2000); expect(result.getMonth()).toBe(6); expect(result.getDate()).toBe(31); }); it('year: 2000, month: 11, day: 30, if {day: 169} was passed', () => { const result = appendDate(y2000m6d15, {day: 169}); expect(result.getFullYear()).toBe(2000); expect(result.getMonth()).toBe(11); expect(result.getDate()).toBe(30); }); it('year: 2000, month: 11, day: 31, if {day: 170} was passed', () => { const result = appendDate(y2000m6d15, {day: 170}); expect(result.getFullYear()).toBe(2000); expect(result.getMonth()).toBe(11); expect(result.getDate()).toBe(31); }); it('year: 2000, month: 0, day: 1, if {day: -197} was passed', () => { const result = appendDate(y2000m6d15, {day: -197}); expect(result.getFullYear()).toBe(2000); expect(result.getMonth()).toBe(0); expect(result.getDate()).toBe(1); }); it('year: 2000, month: 6, day: 1, if {day: -15} was passed', () => { const result = appendDate(y2000m6d15, {day: -15}); expect(result.getFullYear()).toBe(2000); expect(result.getMonth()).toBe(6); expect(result.getDate()).toBe(1); }); it('year: 2000, month: 2, day: 30, if {month: -4, day: 14} was passed', () => { const result = appendDate(y2000m6d15, {month: -4, day: 14}); expect(result.getFullYear()).toBe(2000); expect(result.getMonth()).toBe(2); expect(result.getDate()).toBe(30); }); it('year: 2000, month: 0, day: 1, if {month: -6, day: -15} was passed', () => { const result = appendDate(y2000m6d15, {month: -6, day: -15}); expect(result.getFullYear()).toBe(2000); expect(result.getMonth()).toBe(0); expect(result.getDate()).toBe(1); }); it('year: 1999, month: 0, day: 1, if {month: -6, day: -15, year: -1} was passed', () => { const result = appendDate(y2000m6d15, {month: -6, day: -15, year: -1}); expect(result.getFullYear()).toBe(1999); expect(result.getMonth()).toBe(0); expect(result.getDate()).toBe(1); }); it('year: 2018, month: 1, day: 27, if {month: 1} was passed', () => { const result = appendDate(new Date(2018, 0, 31), {month: 1}); expect(result.getFullYear()).toBe(2018); expect(result.getMonth()).toBe(1); expect(result.getDate()).toBe(27); }); it('year: 2025, month: 0, day: 31, if {month: 1} was passed', () => { const result = appendDate(new Date(2025, 0, 1), {month: 1}); expect(result.getFullYear()).toBe(2025); expect(result.getMonth()).toBe(0); expect(result.getDate()).toBe(31); }); it('year: 2025, month: 8, day: 1, if {month: -1} was passed (for the last day of month)', () => { const result = appendDate(new Date(2025, 8, 30), {month: -1}); expect(result.getFullYear()).toBe(2025); expect(result.getMonth()).toBe(8); expect(result.getDate()).toBe(1); }); it('year: 2025, month: 8, day: 30, if {month: 1} was passed', () => { const result = appendDate(new Date(2025, 8, 1), {month: 1}); expect(result.getFullYear()).toBe(2025); expect(result.getMonth()).toBe(8); expect(result.getDate()).toBe(30); }); it('year: 2018, month: 2, day: 1, if {month: -1} was passed (for the last day of month', () => { const result = appendDate(new Date(2018, 2, 31), {month: -1}); expect(result.getFullYear()).toBe(2018); expect(result.getMonth()).toBe(2); expect(result.getDate()).toBe(1); }); it('year: 2018, month: 2, day: 31, if {month: 1} was passed', () => { const result = appendDate(new Date(2018, 2, 1), {month: 1}); expect(result.getFullYear()).toBe(2018); expect(result.getMonth()).toBe(2); expect(result.getDate()).toBe(31); }); it('year: 2018, month: 1, day: 27, if {month: -1} was passed (when the current month has more days than the final month, and the final month don`t has the day)', () => { const result = appendDate(new Date(2018, 2, 29), {month: -1}); expect(result.getFullYear()).toBe(2018); expect(result.getMonth()).toBe(1); expect(result.getDate()).toBe(27); }); it('year: 2018, month: 1, day: 27, if {month: -1} was passed', () => { const result = appendDate(new Date(2018, 2, 26), {month: -1}); expect(result.getFullYear()).toBe(2018); expect(result.getMonth()).toBe(1); expect(result.getDate()).toBe(27); }); it('year: 2018, month: 2, day: 26, if {month: 1} was passed', () => { const result = appendDate(new Date(2018, 1, 27), {month: 1}); expect(result.getFullYear()).toBe(2018); expect(result.getMonth()).toBe(2); expect(result.getDate()).toBe(26); }); it('year: 2018, month: 1, day: 26, if {month: -1} was passed (when the current month has more days than the final month, but both have the day, and it`s the last day of the final month)', () => { const result = appendDate(new Date(2018, 2, 28), {month: -1}); expect(result.getFullYear()).toBe(2018); expect(result.getMonth()).toBe(1); expect(result.getDate()).toBe(26); }); it('year: 2018, month: 1, day: 26, if {month: -1} was passed', () => { const result = appendDate(new Date(2018, 2, 25), {month: -1}); expect(result.getFullYear()).toBe(2018); expect(result.getMonth()).toBe(1); expect(result.getDate()).toBe(26); }); it('year: 2018, month: 2, day: 25, if {month: 1} was passed', () => { const result = appendDate(new Date(2018, 1, 26), {month: 1}); expect(result.getFullYear()).toBe(2018); expect(result.getMonth()).toBe(2); expect(result.getDate()).toBe(25); }); it('year: 2018, month: 1, day: 28, if {month: -1} was passed (when the current month has more days than the final month, but both has the day)', () => { const result = appendDate(new Date(2018, 2, 27), {month: -1}); expect(result.getFullYear()).toBe(2018); expect(result.getMonth()).toBe(1); expect(result.getDate()).toBe(28); }); it('year: 2018, month: 1, day: 28, if {month: -1} was passed (when the current month has more days than the final month, and the final month don`t has the day)', () => { const result = appendDate(new Date(2018, 2, 30), {month: -1}); expect(result.getFullYear()).toBe(2018); expect(result.getMonth()).toBe(1); expect(result.getDate()).toBe(28); }); it('year: 2018, month: 2, day: 30, if {month: 1} was passed (when the current month has more days than the final month, but both has the day)', () => { const result = appendDate(new Date(2018, 1, 28), {month: 1}); expect(result.getFullYear()).toBe(2018); expect(result.getMonth()).toBe(2); expect(result.getDate()).toBe(30); }); }); ================================================ FILE: projects/kit/src/lib/utils/date/tests/get-date-segment-value-length.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {getDateSegmentValueLength} from '../date-segment-value-length'; describe('getDateSegmentValueLength', () => { it('short date', () => { expect(getDateSegmentValueLength('mm.yy')).toEqual({day: 0, month: 2, year: 2}); }); it('full date', () => { expect(getDateSegmentValueLength('dd.mm.yyyy')).toEqual({ day: 2, month: 2, year: 4, }); }); }); ================================================ FILE: projects/kit/src/lib/utils/date/tests/parse-date-range-string.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {parseDateRangeString} from '../parse-date-range-string'; describe('parseDateRangeString', () => { const tests = [ ['', []], ['1', ['1']], ['13', ['13']], ['13.', ['13.']], ['13.0', ['13.0']], ['13.02', ['13.02']], ['13.02.', ['13.02.']], ['13.02.2023', ['13.02.2023']], ['13.02.2023 ', ['13.02.2023']], ['13.02.2023 –', ['13.02.2023']], ['13.02.2023 – ', ['13.02.2023']], ['13.02.2023 – 14', ['13.02.2023', '14']], ['13.02.2023 – 14.', ['13.02.2023', '14.']], ['13.02.2023 – 14.03.2025', ['13.02.2023', '14.03.2025']], ['13.02.202314.03.2025', ['13.02.2023', '14.03.2025']], ['13.02.202314032025', ['13.02.2023', '14032025']], ['1302202314032025', ['13022023', '14032025']], ] as const; tests.forEach(([dateRangeString, expectedParsedDates]) => { it(`${dateRangeString} => ${JSON.stringify(expectedParsedDates)}`, () => { expect(parseDateRangeString(dateRangeString, 'dd.mm.yyyy', ' – ')).toEqual( expectedParsedDates, ); }); }); }); ================================================ FILE: projects/kit/src/lib/utils/date/to-date-string.ts ================================================ import {DATE_TIME_SEPARATOR} from '../../masks/date-time/constants'; import type {MaskitoDateSegments, MaskitoTimeSegments} from '../../types'; export function toDateString( segments: Partial, options: { dateMode: string; }, ): string; export function toDateString( segments: Partial, options: { dateMode: string; dateTimeSeparator: string; timeMode: string; }, ): string; export function toDateString( { day, month, year, hours, minutes, seconds, milliseconds, }: Partial, { dateMode, dateTimeSeparator = DATE_TIME_SEPARATOR, timeMode, }: { dateMode: string; dateTimeSeparator?: string; timeMode?: string; }, ): string { const yearLength = dateMode.match(/y/g)?.length ?? 0; const fullMode = `${dateMode}${timeMode ? `${dateTimeSeparator}${timeMode}` : ''}`; return fullMode .replaceAll(/d+/g, day ?? '') .replaceAll(/m+/g, month ?? '') .replaceAll(/y+/g, year?.slice(-yearLength) ?? '') .replaceAll(/H+/g, hours ?? '') .replaceAll('MSS', milliseconds ?? '') .replaceAll(/M+/g, minutes ?? '') .replaceAll(/S+/g, seconds ?? '') .replaceAll(/^\D+/g, '') .replaceAll(/\D+$/g, ''); } ================================================ FILE: projects/kit/src/lib/utils/date/validate-date-string.ts ================================================ import {DATE_SEGMENTS_MAX_VALUES} from '../../constants'; import type {MaskitoDateSegments} from '../../types'; import {getDateSegmentValueLength} from './date-segment-value-length'; import {getDateSegmentsOrder} from './get-date-segments-order'; import {parseDateString} from './parse-date-string'; import {toDateString} from './to-date-string'; export function validateDateString({ dateString, dateModeTemplate, dateSegmentsSeparator, offset, selection: [from, to], }: { dateString: string; dateSegmentsSeparator: string; dateModeTemplate: string; offset: number; selection: [number, number]; }): {validatedDateString: string; updatedSelection: [number, number]} { const parsedDate = parseDateString(dateString, dateModeTemplate); const dateSegments = Object.entries(parsedDate) as Array< [keyof MaskitoDateSegments, string] >; const segmentsOrder = getDateSegmentsOrder(dateModeTemplate); const validatedDateSegments: Partial = {}; for (let i = 0; i < dateSegments.length; i++) { const [segmentName, segmentValue] = dateSegments[i]!; const validatedDate = toDateString(validatedDateSegments, { dateMode: dateModeTemplate, }); const maxSegmentValue = DATE_SEGMENTS_MAX_VALUES[segmentName]; const fantomSeparator = validatedDate.length && dateSegmentsSeparator.length; const lastSegmentDigitIndex = offset + validatedDate.length + fantomSeparator + getDateSegmentValueLength(dateModeTemplate)[segmentName]; const isLastSegmentDigitAdded = lastSegmentDigitIndex >= from && lastSegmentDigitIndex === to; if (isLastSegmentDigitAdded && Number(segmentValue) > Number(maxSegmentValue)) { const nextSegment = segmentsOrder[segmentsOrder.indexOf(segmentName) + 1]; if (!nextSegment || nextSegment === 'year') { // 31.1|0.2010 => Type 9 => 31.1|0.2010 return {validatedDateString: '', updatedSelection: [from, to]}; // prevent changes } validatedDateSegments[segmentName] = `0${segmentValue.slice(0, -1)}`; dateSegments[i + 1] = [ nextSegment, `${segmentValue.slice(-1)}${(dateSegments[i + 1]?.[1] ?? '').slice(1)}`, ]; continue; } if (isLastSegmentDigitAdded && Number(segmentValue) < 1) { // 31.0|1.2010 => Type 0 => 31.0|1.2010 return {validatedDateString: '', updatedSelection: [from, to]}; // prevent changes } validatedDateSegments[segmentName] = segmentValue; } const validatedDateString = toDateString(validatedDateSegments, { dateMode: dateModeTemplate, }); const addedDateSegmentSeparators = validatedDateString.length - dateString.length; return { validatedDateString, updatedSelection: [ from + addedDateSegmentSeparators, to + addedDateSegmentSeparators, ], }; } ================================================ FILE: projects/kit/src/lib/utils/dummy.ts ================================================ export function identity(x: T): T { return x; } // eslint-disable-next-line @typescript-eslint/no-empty-function export function noop(): void {} ================================================ FILE: projects/kit/src/lib/utils/escape-reg-exp.ts ================================================ /** * Copy-pasted solution from lodash * @see https://lodash.com/docs/4.17.15#escapeRegExp */ const reRegExpChar = /[\\^$.*+?()[\]{}|]/g; const reHasRegExpChar = new RegExp(reRegExpChar.source); export function escapeRegExp(str: string): string { return str && reHasRegExpChar.test(str) ? str.replaceAll(reRegExpChar, String.raw`\$&`) : str; } ================================================ FILE: projects/kit/src/lib/utils/find-common-beginning-substr.ts ================================================ export function findCommonBeginningSubstr(a: string, b: string): string { let res = ''; for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) { return res; } res += a[i]; } return res; } ================================================ FILE: projects/kit/src/lib/utils/index.ts ================================================ export * from './clamp'; export * from './count-digits'; export * from './date/append-date'; export * from './date/date-segment-value-length'; export * from './date/date-to-segments'; export * from './date/get-date-segments-order'; export * from './date/get-first-complete-date'; export * from './date/is-date-string-complete'; export * from './date/parse-date-range-string'; export * from './date/parse-date-string'; export * from './date/segments-to-date'; export * from './date/to-date-string'; export * from './date/validate-date-string'; export * from './dummy'; export * from './escape-reg-exp'; export * from './find-common-beginning-substr'; export * from './is-empty'; export * from './pad-with-zeroes-until-valid'; export * from './to-half-width-colon'; export * from './to-half-width-number'; ================================================ FILE: projects/kit/src/lib/utils/is-empty.ts ================================================ export function isEmpty(entity?: object | null): boolean { return !entity || (typeof entity === 'object' && Object.keys(entity).length === 0); } ================================================ FILE: projects/kit/src/lib/utils/pad-with-zeroes-until-valid.ts ================================================ const ALL_ZEROES_RE = /^0+$/; export function padWithZeroesUntilValid( segmentValue: string, paddedMaxValue: string, prefixedZeroesCount = 0, ): {prefixedZeroesCount: number; validatedSegmentValue: string} { const paddedSegmentValue = segmentValue.padEnd(paddedMaxValue.length, '0'); if (Number(paddedSegmentValue) <= Number(paddedMaxValue)) { return {validatedSegmentValue: segmentValue, prefixedZeroesCount}; } if (paddedSegmentValue.endsWith('0')) { // 00:|00 => Type 9 => 00:09| return padWithZeroesUntilValid( `0${segmentValue.slice(0, paddedMaxValue.length - 1)}`, paddedMaxValue, prefixedZeroesCount + 1, ); } const valueWithoutLastChar = segmentValue.slice(0, paddedMaxValue.length - 1); if (ALL_ZEROES_RE.exec(valueWithoutLastChar)) { return {validatedSegmentValue: '', prefixedZeroesCount}; } // |19:00 => Type 2 => 2|0:00 return padWithZeroesUntilValid( `${valueWithoutLastChar}0`, paddedMaxValue, prefixedZeroesCount, ); } ================================================ FILE: projects/kit/src/lib/utils/tests/clamp.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {clamp} from '../clamp'; describe('clamp', () => { describe('number', () => { it('returns the original value when it satisfies limits', () => { expect(clamp(5, 0, 10)).toBe(5); }); it('returns the minimum when the value is below the lower bound', () => { expect(clamp(-5, 0, 10)).toBe(0); }); it('returns the maximum when the value exceeds the upper bound', () => { expect(clamp(15, 0, 10)).toBe(10); }); it('ignores a null minimum and keeps enforcing the maximum', () => { expect(clamp(15, null, 10)).toBe(10); }); it('ignores a null maximum and keeps enforcing the minimum', () => { expect(clamp(-5, 0, null)).toBe(0); }); it('returns the incoming value when both bounds are null', () => { expect(clamp(7, null, null)).toBe(7); }); }); describe('bigint', () => { it('returns the original value when it satisfies limits', () => { expect(clamp(7n, 0n, 10n)).toBe(7n); }); it('returns the minimum when the value is below the lower bound', () => { expect(clamp(-5n, 0n, 10n)).toBe(0n); }); it('returns the maximum when the value exceeds the upper bound', () => { expect(clamp(15n, 0n, 10n)).toBe(10n); }); it('ignores a null minimum and keeps enforcing the maximum', () => { expect(clamp(15n, null, 10n)).toBe(10n); }); it('ignores a null maximum and keeps enforcing the minimum', () => { expect(clamp(-5n, 0n, null)).toBe(0n); }); it('returns the incoming value when both bounds are null', () => { expect(clamp(42n, null, null)).toBe(42n); }); it('handles negative bigint values', () => { expect(clamp(-10n, -5n, 5n)).toBe(-5n); expect(clamp(-3n, -5n, 5n)).toBe(-3n); }); }); describe('number with decimal part + min/max as bigint', () => { it('clamps decimal values below the bigint lower bound', () => { expect(clamp(1.5, 2n, 5n)).toBe(2n); }); it('keeps decimal values intact when they fall within bigint bounds', () => { expect(clamp(3.123456789, 2n, 5n)).toBe(3.123456789); }); it('clamps decimal values above the bigint upper bound', () => { expect(clamp(6.98765432, 2n, 5n)).toBe(5n); }); }); describe('value is bigint + min/max as numbers with decimal point', () => { it('clamps bigint below the numeric lower bound', () => { expect(clamp(1n, 1.5, 5.5)).toBe(1.5); }); it('returns bigint when it lies within numeric bounds', () => { expect(clamp(3n, 1.5, 5.5)).toBe(3n); }); it('clamps bigint above the numeric upper bound', () => { expect(clamp(10n, 1.5, 5.5)).toBe(5.5); }); it('handles negative bigint with decimal bounds', () => { expect(clamp(-10n, -5.5, 5.5)).toBe(-5.5); expect(clamp(-3n, -5.5, 5.5)).toBe(-3n); }); }); describe('Date', () => { it('returns the original value (with same reference!) when it falls inside the inclusive range', () => { const lowerBound = new Date('2020-01-01T00:00:00Z'); const value = new Date('2020-01-02T00:00:00Z'); const upperBound = new Date('2020-01-03T00:00:00Z'); expect(clamp(value, lowerBound, upperBound)).toBe(value); }); it('returns the boundary when value falls outside the range', () => { const lowerBound = new Date('2020-01-01T00:00:00Z'); const upperBound = new Date('2020-01-03T00:00:00Z'); expect(clamp(new Date('2019-12-31T23:59:59Z'), lowerBound, upperBound)).toBe( lowerBound, ); expect(clamp(new Date('2020-01-03T12:00:00Z'), lowerBound, upperBound)).toBe( upperBound, ); }); }); }); ================================================ FILE: projects/kit/src/lib/utils/tests/escape-reg-exp.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {CHAR_HYPHEN, CHAR_MINUS} from '../../constants'; import {escapeRegExp} from '../escape-reg-exp'; describe('escapeRegExp', () => { it('escapes dot', () => { const rawRegExpStr = 'a.b'; const escaped = escapeRegExp(rawRegExpStr); const testString = '-abb-a.b-'; expect(escaped).toBe(String.raw`a\.b`); expect(testString.replace(new RegExp(escaped), '')).toBe('-abb--'); expect(testString.replace(new RegExp(rawRegExpStr), '')).toBe('--a.b-'); }); it('escapes dollar sign', () => { const rawRegExpStr = '10$'; const escaped = escapeRegExp(rawRegExpStr); const testString = '-10$-10'; expect(escaped).toBe(String.raw`10\$`); expect(testString.replace(new RegExp(escaped), '')).toBe('--10'); expect(testString.replace(new RegExp(rawRegExpStr), '')).toBe('-10$-'); }); it('escapes plus', () => { const rawRegExpStr = '+'; const escaped = escapeRegExp(rawRegExpStr); const testString = '+42'; expect(escaped).toBe(String.raw`\+`); expect(testString.replace(new RegExp(escaped), '')).toBe('42'); expect(() => // eslint-disable-next-line regexp/no-invalid-regexp testString.replace(new RegExp(rawRegExpStr), ''), ).toThrow(new SyntaxError('Invalid regular expression: /+/: Nothing to repeat')); }); describe('Symbols which do not require escaping', () => { it('minus', () => { expect(escapeRegExp(CHAR_MINUS)).toBe(CHAR_MINUS); expect(`${CHAR_MINUS}42`.replace(new RegExp(CHAR_MINUS), '')).toBe('42'); }); it('hyphen', () => { expect(escapeRegExp(CHAR_HYPHEN)).toBe(CHAR_HYPHEN); expect(`${CHAR_HYPHEN}42`.replace(new RegExp(CHAR_HYPHEN), '')).toBe('42'); }); }); }); ================================================ FILE: projects/kit/src/lib/utils/tests/find-common-beginning-substr.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {findCommonBeginningSubstr} from '../find-common-beginning-substr'; describe('findCommonBeginningSubstr', () => { it('returns common substring until all characters are equal', () => { expect(findCommonBeginningSubstr('123_456', '123456')).toBe('123'); }); it('returns empty string if any string is empty', () => { expect(findCommonBeginningSubstr('123_456', '')).toBe(''); expect(findCommonBeginningSubstr('', '123_456')).toBe(''); }); it('returns empty string if the first characters are different', () => { expect(findCommonBeginningSubstr('012345', '123')).toBe(''); }); it('returns the whole string if all characters are equal', () => { expect(findCommonBeginningSubstr('777999', '777999')).toBe('777999'); }); }); ================================================ FILE: projects/kit/src/lib/utils/tests/get-first-complete-date.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {getFirstCompleteDate} from '../date/get-first-complete-date'; describe('getFirstCompleteDate', () => { it('should return the first complete date', () => { expect(getFirstCompleteDate('01.01.2000-11.11.2000', 'DD.MM.YYYY')).toBe( '01.01.2000', ); expect(getFirstCompleteDate('01.2000-11.2000', 'MM.YYYY')).toBe('01.2000'); expect(getFirstCompleteDate('01.01.2000,23:59', 'DD.MM.YYYY')).toBe('01.01.2000'); }); it('should return empty string if no complete date', () => { expect(getFirstCompleteDate('01.01.20', 'DD.MM.YYYY')).toBe(''); expect(getFirstCompleteDate('01.01.200', 'DD.MM.YYYY HH:mm')).toBe(''); }); it('should return empty string if no date', () => { expect(getFirstCompleteDate('', 'DD.MM.YYYY')).toBe(''); }); }); ================================================ FILE: projects/kit/src/lib/utils/tests/is-empty.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {isEmpty} from '../is-empty'; describe('isEmpty', () => { describe('returns `true` for', () => { it('empty object', () => { expect(isEmpty({})).toBe(true); }); it('zero-length array', () => { expect(isEmpty([])).toBe(true); }); it('null', () => { expect(isEmpty(null)).toBe(true); }); it('undefined', () => { expect(isEmpty()).toBe(true); }); }); describe('returns `false` for', () => { it('non-empty object', () => { expect(isEmpty({name: ''})).toBe(false); }); it('not zero-length array', () => { expect(isEmpty([0])).toBe(false); }); }); }); ================================================ FILE: projects/kit/src/lib/utils/tests/to-half-width-colon.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {toHalfWidthColon} from '../to-half-width-colon'; describe('`toHalfWidthColon` utility converts full width colon to half width colon', () => { it(': => :', () => { expect(toHalfWidthColon(':')).toBe(':'); }); }); ================================================ FILE: projects/kit/src/lib/utils/tests/to-half-width-number.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {toHalfWidthNumber} from '../to-half-width-number'; describe('`toHalfWidthNumber` utility converts full width numbers to half width numbers', () => { const tests = [ // [full width value, half width value] ['1', '1'], ['2', '2'], ['3', '3'], ['4', '4'], ['5', '5'], ['6', '6'], ['7', '7'], ['8', '8'], ['9', '9'], ] as const; tests.forEach(([fullWidthValue, halfWidthValue]) => { it(`${fullWidthValue} => ${halfWidthValue}`, () => { expect(toHalfWidthNumber(fullWidthValue)).toBe(halfWidthValue); }); }); it('123456 => 123456', () => { expect(toHalfWidthNumber('123456')).toBe('123456'); }); it('123456 (full width + half width mix) => 123456', () => { expect(toHalfWidthNumber('123456')).toBe('123456'); }); }); ================================================ FILE: projects/kit/src/lib/utils/time/create-time-mask-expression.ts ================================================ import {CHAR_NO_BREAK_SPACE, TIME_FIXED_CHARACTERS} from '../../constants'; import type {MaskitoTimeMode} from '../../types'; export function createTimeMaskExpression(mode: MaskitoTimeMode): Array { return Array.from(mode.replace(' AA', '')) .map((char) => (TIME_FIXED_CHARACTERS.includes(char) ? char : /\d/)) .concat(mode.includes('AA') ? [CHAR_NO_BREAK_SPACE, /[AP]/i, /M/i] : []); } ================================================ FILE: projects/kit/src/lib/utils/time/enrich-time-segments-with-zeroes.ts ================================================ import {DEFAULT_TIME_SEGMENT_MAX_VALUES, TIME_FIXED_CHARACTERS} from '../../constants'; import type {MaskitoTimeMode, MaskitoTimeSegments} from '../../types'; import {escapeRegExp} from '../escape-reg-exp'; import {padWithZeroesUntilValid} from '../pad-with-zeroes-until-valid'; import {padStartTimeSegments} from './pad-start-time-segments'; import {parseTimeString} from './parse-time-string'; import {toTimeString} from './to-time-string'; const TRAILING_TIME_SEGMENT_SEPARATOR_REG = new RegExp( `[${TIME_FIXED_CHARACTERS.map(escapeRegExp).join('')}]$`, ); /** * Pads invalid time segment with zero to make it valid. * @example 00:|00 => Type 9 (too much for the first digit of minutes) => 00:09| * @example |19:00 => Type 2 (29 - invalid value for hours) => 2|0:00 */ export function enrichTimeSegmentsWithZeroes( {value, selection}: {value: string; selection: readonly [number, number]}, { mode, timeSegmentMaxValues = DEFAULT_TIME_SEGMENT_MAX_VALUES, }: { mode: MaskitoTimeMode; timeSegmentMaxValues?: MaskitoTimeSegments; }, ): {value: string; selection: readonly [number, number]} { const [from, to] = selection; const parsedTime = parseTimeString(value, mode); const possibleTimeSegments = Object.entries(parsedTime) as Array< [keyof MaskitoTimeSegments, string] >; const paddedMaxValues = padStartTimeSegments(timeSegmentMaxValues); const validatedTimeSegments: Partial = {}; let paddedZeroes = 0; for (const [segmentName, segmentValue] of possibleTimeSegments) { const maxSegmentValue = paddedMaxValues[segmentName]; const {validatedSegmentValue, prefixedZeroesCount} = padWithZeroesUntilValid( segmentValue, String(maxSegmentValue), ); paddedZeroes += prefixedZeroesCount; validatedTimeSegments[segmentName] = validatedSegmentValue; } const [leadingNonDigitCharacters = ''] = value.match(/^\D+(?=\d)/g) || []; // prefix const [trailingNonDigitCharacters = ''] = value.match(/\D+$/g) || []; // trailing segment separators / meridiem characters / postfix const validatedTimeString = `${leadingNonDigitCharacters}${toTimeString(validatedTimeSegments)}${trailingNonDigitCharacters}`; const addedTimeSegmentSeparators = Math.max( validatedTimeString.length - value.length - paddedZeroes, 0, ); let newFrom = from + paddedZeroes + addedTimeSegmentSeparators; let newTo = to + paddedZeroes + addedTimeSegmentSeparators; if ( newFrom === newTo && paddedZeroes && // if next character after cursor is time segment separator validatedTimeString.slice(0, newTo + 1).match(TRAILING_TIME_SEGMENT_SEPARATOR_REG) ) { newFrom++; newTo++; } return { value: validatedTimeString, selection: [newFrom, newTo], }; } ================================================ FILE: projects/kit/src/lib/utils/time/index.ts ================================================ export * from './create-time-mask-expression'; export * from './enrich-time-segments-with-zeroes'; export * from './pad-end-time-segments'; export * from './pad-start-time-segments'; export * from './parse-time-string'; export * from './to-time-string'; ================================================ FILE: projects/kit/src/lib/utils/time/pad-end-time-segments.ts ================================================ import type {MaskitoTimeSegments} from '../../types'; import {padTimeSegments} from './pad-time-segments'; export function padEndTimeSegments( timeSegments: MaskitoTimeSegments, ): MaskitoTimeSegments; export function padEndTimeSegments( timeSegments: Partial>, ): Partial; export function padEndTimeSegments( timeSegments: Partial>, ): Partial { return padTimeSegments(timeSegments, (value, length) => value.padEnd(length, '0')); } ================================================ FILE: projects/kit/src/lib/utils/time/pad-start-time-segments.ts ================================================ import type {MaskitoTimeSegments} from '../../types'; import {padTimeSegments} from './pad-time-segments'; export function padStartTimeSegments( timeSegments: MaskitoTimeSegments, ): MaskitoTimeSegments; export function padStartTimeSegments( timeSegments: Partial>, ): Partial; export function padStartTimeSegments( timeSegments: Partial>, ): Partial { return padTimeSegments(timeSegments, (value, length) => value.padStart(length, '0')); } ================================================ FILE: projects/kit/src/lib/utils/time/pad-time-segments.ts ================================================ import {TIME_SEGMENT_VALUE_LENGTHS} from '../../constants'; import type {MaskitoTimeSegments} from '../../types'; export function padTimeSegments( timeSegments: MaskitoTimeSegments, pad: (segmentValue: string, segmentLength: number) => string, ): MaskitoTimeSegments; export function padTimeSegments( timeSegments: Partial>, pad: (segmentValue: string, segmentLength: number) => string, ): Partial; export function padTimeSegments( timeSegments: Partial>, pad: (segmentValue: string, segmentLength: number) => string, ): Partial { return Object.fromEntries( Object.entries(timeSegments).map(([segmentName, segmentValue]) => [ segmentName, pad( String(segmentValue), TIME_SEGMENT_VALUE_LENGTHS[segmentName as keyof MaskitoTimeSegments], ), ]), ); } ================================================ FILE: projects/kit/src/lib/utils/time/parse-time-string.ts ================================================ import type {MaskitoTimeMode, MaskitoTimeSegments} from '../../types'; const SEGMENT_FULL_NAME: Record = { HH: 'hours', MM: 'minutes', SS: 'seconds', MSS: 'milliseconds', }; /** * @param timeString can be with/without fixed characters */ export function parseTimeString( timeString: string, timeMode: MaskitoTimeMode, ): Partial { const onlyDigits = timeString.replaceAll(/\D+/g, ''); let offset = 0; return Object.fromEntries( timeMode .split(/\W/) .filter((segmentAbbr) => SEGMENT_FULL_NAME[segmentAbbr]) .map((segmentAbbr) => { const segmentValue = onlyDigits.slice( offset, offset + segmentAbbr.length, ); offset += segmentAbbr.length; return [SEGMENT_FULL_NAME[segmentAbbr], segmentValue]; }), ); } ================================================ FILE: projects/kit/src/lib/utils/time/tests/enrich-time-segments-with-zeroes.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {enrichTimeSegmentsWithZeroes} from '../enrich-time-segments-with-zeroes'; describe('enrichTimeSegmentsWithZeroes', () => { const fn = (value: string): string => enrichTimeSegmentsWithZeroes({value, selection: [0, 0]}, {mode: 'HH:MM:SS'}) .value; it('all time segments valid', () => { expect(fn('17:43:00')).toBe('17:43:00'); }); it('contains invalid time segment for hours', () => { expect(fn('30:30:30')).toBe('03:30:30'); }); it('invalid time segment for minutes', () => { expect(fn('23:70:30')).toBe('23:07:30'); }); it('invalid time segment for seconds', () => { expect(fn('23:07:90')).toBe('23:07:09'); }); }); ================================================ FILE: projects/kit/src/lib/utils/time/tests/parse-time-string.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {parseTimeString} from '../parse-time-string'; describe('parseTimeString', () => { it('hh', () => { expect(parseTimeString('19', 'HH')).toEqual({hours: '19'}); }); it('hh:mm', () => { expect(parseTimeString('23:59', 'HH:MM')).toEqual({ hours: '23', minutes: '59', }); }); it('hh:mm:ss', () => { expect(parseTimeString('12:24:55', 'HH:MM:SS')).toEqual({ hours: '12', minutes: '24', seconds: '55', }); }); it('hh:mm:ss.mss', () => { expect(parseTimeString('10:05:42.783', 'HH:MM:SS.MSS')).toEqual({ hours: '10', minutes: '05', seconds: '42', milliseconds: '783', }); }); it('mm:ss.mss', () => { expect(parseTimeString('12:30.001', 'MM:SS.MSS')).toEqual({ minutes: '12', seconds: '30', milliseconds: '001', }); }); it('ss.mss', () => { expect(parseTimeString('59.999', 'SS.MSS')).toEqual({ seconds: '59', milliseconds: '999', }); }); it('mm:ss', () => { expect(parseTimeString('25:55', 'MM:SS')).toEqual({ minutes: '25', seconds: '55', }); }); }); ================================================ FILE: projects/kit/src/lib/utils/time/tests/to-time-string.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {toTimeString} from '../to-time-string'; describe('toTimeString', () => { describe('HH', () => { it('21', () => { expect(toTimeString({hours: '21'})).toBe('21'); }); it('1', () => { expect(toTimeString({hours: '1'})).toBe('1'); }); }); describe('HH:MM', () => { it('21:59', () => { expect(toTimeString({hours: '21', minutes: '59'})).toBe('21:59'); }); it('12:4', () => { expect(toTimeString({hours: '12', minutes: '4'})).toBe('12:4'); }); }); describe('HH:MM:SS', () => { it('21:59:23', () => { expect(toTimeString({hours: '21', minutes: '59', seconds: '23'})).toBe( '21:59:23', ); }); it('01:23:5', () => { expect(toTimeString({hours: '01', minutes: '23', seconds: '5'})).toBe( '01:23:5', ); }); }); describe('HH:MM:SS.MSS', () => { it('21:59:23.111', () => { expect( toTimeString({ hours: '21', minutes: '59', seconds: '23', milliseconds: '111', }), ).toBe('21:59:23.111'); }); it('01:23:52.1', () => { expect( toTimeString({ hours: '01', minutes: '23', seconds: '52', milliseconds: '1', }), ).toBe('01:23:52.1'); }); it('13:13:13.15', () => { expect( toTimeString({ hours: '13', minutes: '13', seconds: '13', milliseconds: '15', }), ).toBe('13:13:13.15'); }); }); describe('MM:SS.MSS', () => { it('12:12.111', () => { expect( toTimeString({ minutes: '12', seconds: '12', milliseconds: '111', }), ).toBe('12:12.111'); }); it('23:01.9', () => { expect( toTimeString({ minutes: '23', seconds: '01', milliseconds: '9', }), ).toBe('23:01.9'); }); it('00:02.91', () => { expect( toTimeString({ minutes: '00', seconds: '02', milliseconds: '91', }), ).toBe('00:02.91'); }); }); describe('SS.MSS', () => { it('12.111', () => { expect( toTimeString({ seconds: '12', milliseconds: '111', }), ).toBe('12.111'); }); it('01.9', () => { expect( toTimeString({ seconds: '01', milliseconds: '9', }), ).toBe('01.9'); }); it('02.91', () => { expect( toTimeString({ seconds: '02', milliseconds: '91', }), ).toBe('02.91'); }); }); describe('MM:SS', () => { it('12:11', () => { expect( toTimeString({ minutes: '12', seconds: '11', }), ).toBe('12:11'); }); it('01:09', () => { expect( toTimeString({ minutes: '01', seconds: '09', }), ).toBe('01:09'); }); it('02:55', () => { expect( toTimeString({ minutes: '02', seconds: '55', }), ).toBe('02:55'); }); }); }); ================================================ FILE: projects/kit/src/lib/utils/time/to-time-string.ts ================================================ import type {MaskitoTimeSegments} from '../../types'; const LEADING_NON_DIGITS = /^\D*/; const TRAILING_NON_DIGITS = /\D*$/; export function toTimeString({ hours = '', minutes = '', seconds = '', milliseconds = '', }: Partial): string { return `${hours}:${minutes}:${seconds}.${milliseconds}` .replace(LEADING_NON_DIGITS, '') .replace(TRAILING_NON_DIGITS, ''); } ================================================ FILE: projects/kit/src/lib/utils/to-half-width-colon.ts ================================================ import {CHAR_COLON, CHAR_JP_COLON} from '../constants'; /** * Replace fullwidth colon with half width colon * @param fullWidthColon full width colon * @returns processed half width colon */ export function toHalfWidthColon(fullWidthColon: string): string { return fullWidthColon.replaceAll(new RegExp(CHAR_JP_COLON, 'g'), CHAR_COLON); } ================================================ FILE: projects/kit/src/lib/utils/to-half-width-number.ts ================================================ /** * Replace fullwidth numbers with half width number * @param fullWidthNumber full width number * @returns processed half width number */ export function toHalfWidthNumber(fullWidthNumber: string): string { return fullWidthNumber.replaceAll(/[0-9]/g, (s) => String.fromCharCode(s.charCodeAt(0) - 0xfee0), ); } ================================================ FILE: projects/phone/README.md ================================================ # @maskito/phone [![npm version](https://img.shields.io/npm/v/@maskito/phone.svg)](https://npmjs.com/package/@maskito/phone) [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@maskito/phone)](https://bundlephobia.com/result?p=@maskito/phone)

Maskito logo

DocumentationSubmit an IssueContact Us

> The optional framework-agnostic Maskito's package.
It contains ready-to-use phone mask ## How to install ```bash npm i @maskito/{core,phone} ``` ================================================ FILE: projects/phone/jest.config.ts ================================================ export default { displayName: 'phone', preset: '../../jest.preset.js', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], coverageDirectory: '../../coverage/phone', }; ================================================ FILE: projects/phone/package.json ================================================ { "name": "@maskito/phone", "version": "5.2.2", "description": "The optional framework-agnostic Maskito's package with phone masks", "keywords": [ "input", "mask", "inputmask", "input-mask", "phone", "phonemask", "phone-mask", "format", "input-format", "input-formatting", "javascript", "typescript" ], "homepage": "https://maskito.dev", "bugs": "https://github.com/taiga-family/maskito/issues", "repository": { "type": "git", "url": "https://github.com/taiga-family/maskito.git" }, "license": "Apache-2.0", "author": { "email": "vladimir.potekh@gmail.com", "name": "Vladimir Potekhin", "url": "https://github.com/vladimirpotekhin" }, "contributors": [ { "email": "alexander@inkin.ru", "name": "Alex Inkin" }, { "email": "vladimir.potekh@gmail.com", "name": "Vladimir Potekhin" }, { "email": "nikita.s.barsukov@gmail.com", "name": "Nikita Barsukov" }, { "email": "nextzeddicus@gmail.com", "name": "Georgiy Lunin" } ], "devDependencies": { "libphonenumber-js": "1.12.41" }, "peerDependencies": { "@maskito/core": "^5.2.2", "@maskito/kit": "^5.2.2", "libphonenumber-js": ">=1.0.0" } } ================================================ FILE: projects/phone/project.json ================================================ { "$schema": "../../node_modules/nx/schemas/project-schema.json", "name": "phone", "projectType": "library", "sourceRoot": "projects/phone/src", "tags": [], "targets": { "build": { "dependsOn": [ { "dependencies": true, "params": "forward", "target": "build" } ], "executor": "@nx/rollup:rollup", "options": { "assets": [ { "glob": "README.md", "input": "{projectRoot}", "output": "." } ], "compiler": "tsc", "external": "all", "format": ["esm", "cjs"], "main": "{projectRoot}/src/index.ts", "outputPath": "dist/{projectName}", "project": "{projectRoot}/package.json", "tsConfig": "tsconfig.build.json", "useLegacyTypescriptPlugin": false }, "outputs": ["{options.outputPath}"] }, "publish": { "dependsOn": [ { "params": "ignore", "target": "build" } ], "executor": "nx:run-commands", "options": { "command": "npm publish ./dist/{projectName} --ignore-scripts" } }, "test": { "executor": "@nx/jest:jest", "options": { "jestConfig": "{projectRoot}/jest.config.ts" }, "outputs": ["{workspaceRoot}/coverage/{projectName}"] } } } ================================================ FILE: projects/phone/src/index.ts ================================================ export type {MaskitoPhoneParams} from './lib/masks'; export {maskitoGetCountryFromNumber, maskitoPhoneOptionsGenerator} from './lib/masks'; ================================================ FILE: projects/phone/src/lib/masks/index.ts ================================================ export type {MaskitoPhoneParams} from './phone'; export {maskitoGetCountryFromNumber, maskitoPhoneOptionsGenerator} from './phone'; ================================================ FILE: projects/phone/src/lib/masks/phone/constants/index.ts ================================================ export * from './template-filler'; ================================================ FILE: projects/phone/src/lib/masks/phone/constants/template-filler.ts ================================================ export const TEMPLATE_FILLER = 'x'; ================================================ FILE: projects/phone/src/lib/masks/phone/index.ts ================================================ export {TEMPLATE_FILLER} from './constants'; export type {MaskitoPhoneParams} from './phone-mask'; export {maskitoPhoneOptionsGenerator} from './phone-mask'; export { browserAutofillPreprocessorGenerator, cutInitCountryCodePreprocessor, phoneLengthPostprocessorGenerator, } from './processors'; export {maskitoGetCountryFromNumber} from './utils'; ================================================ FILE: projects/phone/src/lib/masks/phone/phone-mask-non-strict.ts ================================================ import {MASKITO_DEFAULT_OPTIONS, type MaskitoOptions} from '@maskito/core'; import {AsYouType, type CountryCode, type MetadataJson} from 'libphonenumber-js/core'; import { browserAutofillPreprocessorGenerator, pasteNonStrictPhonePreprocessorGenerator, phoneLengthPostprocessorGenerator, sanitizePreprocessor, } from './processors'; import {generatePhoneMask, getPhoneTemplate, selectTemplate} from './utils'; export function maskitoPhoneNonStrictOptionsGenerator({ defaultIsoCode, metadata, separator = '-', }: { defaultIsoCode?: CountryCode; metadata: MetadataJson; separator?: string; }): Required { const formatter = new AsYouType(defaultIsoCode, metadata); const prefix = '+'; let currentTemplate = ''; let currentPhoneLength = 0; return { ...MASKITO_DEFAULT_OPTIONS, mask: ({value}) => { const newTemplate = getPhoneTemplate({ formatter, value, separator, }); const newPhoneLength = value.replaceAll(/\D/g, '').length; currentTemplate = selectTemplate({ currentTemplate, newTemplate, currentPhoneLength, newPhoneLength, }); currentPhoneLength = newPhoneLength; return currentTemplate.length === 1 ? ['+', /\d/] : generatePhoneMask({value, template: currentTemplate, prefix}); }, preprocessors: [ sanitizePreprocessor, browserAutofillPreprocessorGenerator({ prefix, countryIsoCode: defaultIsoCode, metadata, }), pasteNonStrictPhonePreprocessorGenerator({ prefix, countryIsoCode: defaultIsoCode, metadata, }), ], postprocessors: [phoneLengthPostprocessorGenerator(metadata)], }; } ================================================ FILE: projects/phone/src/lib/masks/phone/phone-mask-strict.ts ================================================ import {MASKITO_DEFAULT_OPTIONS, type MaskitoOptions} from '@maskito/core'; import {maskitoCaretGuard, maskitoPrefixPostprocessorGenerator} from '@maskito/kit'; import {AsYouType, getCountryCallingCode} from 'libphonenumber-js/core'; import type {MaskitoPhoneParams} from './phone-mask'; import { browserAutofillPreprocessorGenerator, cutInitCountryCodePreprocessor, pasteStrictPhonePreprocessorGenerator, phoneLengthPostprocessorGenerator, sanitizePreprocessor, } from './processors'; import {generatePhoneMask, getPhoneTemplate, selectTemplate} from './utils'; export function maskitoPhoneStrictOptionsGenerator({ countryIsoCode, metadata, separator = '-', format = 'INTERNATIONAL', }: Omit, 'strict'>): Required { const isNational = format === 'NATIONAL'; const code = getCountryCallingCode(countryIsoCode, metadata); const formatter = new AsYouType(countryIsoCode, metadata); const prefix = isNational ? '' : `+${code} `; let currentTemplate = ''; let currentPhoneLength = 0; return { ...MASKITO_DEFAULT_OPTIONS, mask: ({value}) => { const newTemplate = getPhoneTemplate({ formatter, value, separator, countryIsoCode, metadata, format, }); const newPhoneLength = value.replaceAll(/\D/g, '').length; currentTemplate = selectTemplate({ currentTemplate, newTemplate, currentPhoneLength, newPhoneLength, }); currentPhoneLength = newPhoneLength; return generatePhoneMask({value, template: currentTemplate, prefix}); }, plugins: [ maskitoCaretGuard((value, [from, to]) => [ from === to ? prefix.length : 0, value.length, ]), ], preprocessors: [ sanitizePreprocessor, cutInitCountryCodePreprocessor({countryIsoCode, metadata, format}), browserAutofillPreprocessorGenerator({ prefix, countryIsoCode, metadata, format, }), pasteStrictPhonePreprocessorGenerator({ prefix, countryIsoCode, metadata, format, }), ], postprocessors: isNational ? [phoneLengthPostprocessorGenerator(metadata)] : [ maskitoPrefixPostprocessorGenerator(prefix), phoneLengthPostprocessorGenerator(metadata), ], }; } ================================================ FILE: projects/phone/src/lib/masks/phone/phone-mask.ts ================================================ import type {MaskitoOptions} from '@maskito/core'; import type {CountryCode, MetadataJson, NumberFormat} from 'libphonenumber-js/core'; import {maskitoPhoneNonStrictOptionsGenerator} from './phone-mask-non-strict'; import {maskitoPhoneStrictOptionsGenerator} from './phone-mask-strict'; export interface MaskitoPhoneParams { countryIsoCode?: CountryCode; metadata: MetadataJson; strict?: boolean; separator?: string; /** * Phone number format. * - 'INTERNATIONAL' (default): Includes country code prefix (e.g., +1 212 343-3355) * - 'NATIONAL': Country-specific format without country code (e.g., (212) 343-3355). * Only works with strict mode (requires countryIsoCode). */ format?: Extract; } export function maskitoPhoneOptionsGenerator({ countryIsoCode, metadata, strict = true, separator = '-', format = 'INTERNATIONAL', }: MaskitoPhoneParams): Required { return strict && countryIsoCode ? maskitoPhoneStrictOptionsGenerator({ countryIsoCode, metadata, separator, format, }) : maskitoPhoneNonStrictOptionsGenerator({ defaultIsoCode: countryIsoCode, metadata, separator, }); } ================================================ FILE: projects/phone/src/lib/masks/phone/processors/browser-autofill-preprocessor.ts ================================================ import type {MaskitoPreprocessor} from '@maskito/core'; import { AsYouType, type CountryCode, formatIncompletePhoneNumber, type MetadataJson, parsePhoneNumber, } from 'libphonenumber-js/core'; import type {MaskitoPhoneParams} from '../phone-mask'; function extractNumberValue( value: string, countryIsoCode: CountryCode | undefined, metadata: MetadataJson, ): string { const formatter = new AsYouType(countryIsoCode, metadata); formatter.input(value); const numberValue = formatter.getNumberValue() ?? ''; formatter.reset(); return numberValue; } /** * Converts an international phone value to national format. */ function convertToNationalFormat( value: string, countryIsoCode: CountryCode, metadata: MetadataJson, ): string { const numberValue = extractNumberValue(value, countryIsoCode, metadata); if (!numberValue) { return ''; } try { const phone = parsePhoneNumber(numberValue, countryIsoCode, metadata); return formatIncompletePhoneNumber( phone.nationalNumber, countryIsoCode, metadata, ); } catch { return ''; } } export function browserAutofillPreprocessorGenerator({ prefix, countryIsoCode, metadata, format = 'INTERNATIONAL', }: Pick & { prefix: string; }): MaskitoPreprocessor { const isNational = format === 'NATIONAL'; return ({elementState, data}) => { const {selection, value} = elementState; const cleanCode = prefix.trim(); /** * Handle autocomplete: when value doesn't match expected format. * For international: value should start with '+' or country code. * For national: value should not start with '+'. */ if (value && !data) { if (isNational && value.startsWith('+') && countryIsoCode) { /** * For national format, if autocomplete provides international format, * convert it to national format. */ const formattedNational = convertToNationalFormat( value, countryIsoCode, metadata, ); if (formattedNational) { return {elementState: {value: formattedNational, selection}}; } } else if (!isNational && !value.startsWith(cleanCode)) { /** * International format autocomplete handling. */ const numberValue = extractNumberValue(value, countryIsoCode, metadata); const formatter = new AsYouType(countryIsoCode, metadata); return {elementState: {value: formatter.input(numberValue), selection}}; } } return {elementState}; }; } ================================================ FILE: projects/phone/src/lib/masks/phone/processors/cut-init-country-code-preprocessor.ts ================================================ import type {MaskitoPreprocessor} from '@maskito/core'; import { formatIncompletePhoneNumber, getCountryCallingCode, parsePhoneNumber, } from 'libphonenumber-js/core'; import type {MaskitoPhoneParams} from '../phone-mask'; /** * This preprocessor works only once at initialization phase (when `new Maskito(...)` is executed). * This preprocessor helps to avoid conflicts during transition from one mask to another (for the same input). */ export function cutInitCountryCodePreprocessor({ countryIsoCode, metadata, format, }: Required< Pick >): MaskitoPreprocessor { let isInitializationPhase = true; const code = `+${getCountryCallingCode(countryIsoCode, metadata)} `; return ({elementState, data}) => { if (!isInitializationPhase) { return {elementState, data}; } const {value, selection} = elementState; isInitializationPhase = false; /** * International format: * If the value already starts with the expected prefix (e.g., "+7 "), * don't reformat it. This avoids breaking selection positions when * the input already has a properly formatted value (e.g., an initial * value set on the element before Maskito attaches). * * National format: * If value starts with '+', extract national number. * Otherwise, assume it's already in national format. */ if ( (format === 'INTERNATIONAL' && value.startsWith(code)) || (format === 'NATIONAL' && !value.startsWith('+')) ) { return {elementState}; } try { const {nationalNumber} = parsePhoneNumber(value, countryIsoCode, metadata); return { elementState: { value: format === 'NATIONAL' ? formatIncompletePhoneNumber( nationalNumber, countryIsoCode, metadata, ) : `${code} ${nationalNumber}`, selection, }, }; } catch { return {elementState}; } }; } ================================================ FILE: projects/phone/src/lib/masks/phone/processors/index.ts ================================================ export {browserAutofillPreprocessorGenerator} from './browser-autofill-preprocessor'; export {cutInitCountryCodePreprocessor} from './cut-init-country-code-preprocessor'; export {pasteNonStrictPhonePreprocessorGenerator} from './paste-non-strict-phone-preprocessor'; export {pasteStrictPhonePreprocessorGenerator} from './paste-strict-phone-preprocessor'; export {phoneLengthPostprocessorGenerator} from './phone-length-postprocessor'; export {sanitizePreprocessor} from './sanitize-phone-preprocessor'; ================================================ FILE: projects/phone/src/lib/masks/phone/processors/paste-non-strict-phone-preprocessor.ts ================================================ import type {MaskitoPreprocessor} from '@maskito/core'; import {type PhoneNumber} from 'libphonenumber-js'; import { type CountryCode, type MetadataJson, parsePhoneNumber, } from 'libphonenumber-js/core'; import {type MaskitoPhoneParams} from '../phone-mask'; function parsePhone({ data, prefix, countryIsoCode, metadata, }: { data: string; prefix: string; countryIsoCode?: CountryCode; metadata: MetadataJson; }): PhoneNumber { if (!data.startsWith(prefix) && countryIsoCode) { try { return parsePhoneNumber(`+${data}`, countryIsoCode, metadata); } catch { return parsePhoneNumber(data, countryIsoCode, metadata); } } return parsePhoneNumber(data, metadata); } export function pasteNonStrictPhonePreprocessorGenerator({ prefix, countryIsoCode, metadata, }: Pick & { prefix: string; }): MaskitoPreprocessor { return ({elementState, data}) => ({ elementState, data: data.length > 2 && elementState.value === '' ? parsePhone({ data, prefix, countryIsoCode, metadata, }).number : data, }); } ================================================ FILE: projects/phone/src/lib/masks/phone/processors/paste-strict-phone-preprocessor.ts ================================================ import type {MaskitoPreprocessor} from '@maskito/core'; import {parsePhoneNumber} from 'libphonenumber-js/core'; import type {MaskitoPhoneParams} from '../phone-mask'; export function pasteStrictPhonePreprocessorGenerator({ prefix, countryIsoCode, metadata, format = 'INTERNATIONAL', }: Pick & { prefix: string; }): MaskitoPreprocessor { const isNational = format === 'NATIONAL'; return ({elementState, data}) => { const {selection, value} = elementState; const [from] = selection; const selectionIncludesPrefix = from < prefix.length; // handle paste of a number when input contains only the prefix if (data.length > 2 && value.trim() === prefix.trim()) { // handle paste-event with different code, for example for 8 / +7 const phone = countryIsoCode ? parsePhoneNumber(data, countryIsoCode, metadata) : parsePhoneNumber(data, metadata); const {nationalNumber, countryCallingCode} = phone; if (isNational && countryIsoCode) { /** * For national format, always return just the national number. * The mask will format it according to the country's national format. */ return { elementState: { selection, value: '', }, data: nationalNumber, }; } return { elementState: { selection, value: selectionIncludesPrefix ? '' : prefix, }, data: selectionIncludesPrefix ? `+${countryCallingCode} ${nationalNumber}` : nationalNumber, }; } return {elementState}; }; } ================================================ FILE: projects/phone/src/lib/masks/phone/processors/phone-length-postprocessor.ts ================================================ import type {MaskitoPostprocessor} from '@maskito/core'; import type {MetadataJson} from 'libphonenumber-js/core'; import {cutPhoneByValidLength} from '../utils'; const MIN_LENGTH = 3; export function phoneLengthPostprocessorGenerator( metadata: MetadataJson, ): MaskitoPostprocessor { return ({value, selection}) => ({ value: value.length > MIN_LENGTH ? cutPhoneByValidLength({phone: value, metadata}) : value, selection, }); } ================================================ FILE: projects/phone/src/lib/masks/phone/processors/sanitize-phone-preprocessor.ts ================================================ import type {MaskitoPreprocessor} from '@maskito/core'; export const sanitizePreprocessor: MaskitoPreprocessor = ({elementState, data}) => ({ elementState, data: data.replaceAll(/[^\d+]/g, ''), }); ================================================ FILE: projects/phone/src/lib/masks/phone/tests/get-phone-template.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {AsYouType} from 'libphonenumber-js/core'; import metadata from 'libphonenumber-js/min/metadata'; import {getPhoneTemplate} from '../utils/get-phone-template'; describe('getPhoneTemplate', () => { describe('International format', () => { it('generates template for value with "+" prefix', () => { const formatter = new AsYouType(undefined, metadata); const template = getPhoneTemplate({ formatter, value: '+12125552365', separator: '-', }); // Template uses 'x' for all digit positions (including country code) expect(template).toBe('xx xxx xxx-xxxx'); }); it('generates same template for value without "+" prefix (pasted number)', () => { const formatter = new AsYouType(undefined, metadata); const template = getPhoneTemplate({ formatter, value: '12125552365', separator: '-', }); // Should produce the same template as with '+' prefix expect(template).toBe('xx xxx xxx-xxxx'); }); it('returns empty template for empty value', () => { const formatter = new AsYouType(undefined, metadata); const template = getPhoneTemplate({ formatter, value: '', separator: '-', }); expect(template).toBe(''); }); it('returns single "x" for value with only "+"', () => { const formatter = new AsYouType(undefined, metadata); const template = getPhoneTemplate({ formatter, value: '+', separator: '-', }); expect(template).toBe('x'); }); it('handles formatted value with spaces', () => { const formatter = new AsYouType(undefined, metadata); const template = getPhoneTemplate({ formatter, value: '+1 212 555 2365', separator: '-', }); expect(template).toBe('xx xxx xxx-xxxx'); }); it('handles value with only formatting characters (no digits)', () => { const formatter = new AsYouType(undefined, metadata); const template = getPhoneTemplate({ formatter, value: '( ) -', separator: '-', }); expect(template).toBe(''); }); describe('pasting numbers without "+" prefix produces correct template', () => { it('using US number: 12125552365', () => { const formatter = new AsYouType(undefined, metadata); const templateWithPlus = getPhoneTemplate({ formatter, value: '+12125552365', separator: '-', }); formatter.reset(); const templateWithoutPlus = getPhoneTemplate({ formatter, value: '12125552365', separator: '-', }); // Both should produce the same template expect(templateWithoutPlus).toBe(templateWithPlus); expect(templateWithoutPlus).toBe('xx xxx xxx-xxxx'); }); it('using RU number: 79202800155', () => { const formatter = new AsYouType(undefined, metadata); const templateWithPlus = getPhoneTemplate({ formatter, value: '+79202800155', separator: '-', }); formatter.reset(); const templateWithoutPlus = getPhoneTemplate({ formatter, value: '79202800155', separator: '-', }); // Both should produce the same template expect(templateWithoutPlus).toBe(templateWithPlus); expect(templateWithoutPlus).toBe('xx xxx xxx-xx-xx'); }); it('using BY number: 375447488269', () => { const formatter = new AsYouType(undefined, metadata); const templateWithPlus = getPhoneTemplate({ formatter, value: '+375447488269', separator: '-', }); formatter.reset(); const templateWithoutPlus = getPhoneTemplate({ formatter, value: '375447488269', separator: '-', }); // Both should produce the same template expect(templateWithoutPlus).toBe(templateWithPlus); expect(templateWithoutPlus).toBe('xxxx xx xxx-xx-xx'); }); }); describe('custom separator', () => { it('uses space as separator', () => { const formatter = new AsYouType(undefined, metadata); const template = getPhoneTemplate({ formatter, value: '+12125552365', separator: ' ', }); expect(template).toBe('xx xxx xxx xxxx'); }); it('uses dot as separator', () => { const formatter = new AsYouType(undefined, metadata); const template = getPhoneTemplate({ formatter, value: '+12125552365', separator: '.', }); expect(template).toBe('xx xxx xxx.xxxx'); }); }); }); describe('National format', () => { it('generates US national template', () => { const formatter = new AsYouType('US', metadata); const template = getPhoneTemplate({ formatter, value: '2125552365', separator: '-', countryIsoCode: 'US', metadata, format: 'NATIONAL', }); expect(template).toBe('(xxx) xxx-xxxx'); }); it('generates RU national template', () => { const formatter = new AsYouType('RU', metadata); const template = getPhoneTemplate({ formatter, value: '9202800155', separator: '-', countryIsoCode: 'RU', metadata, format: 'NATIONAL', }); expect(template).toBe('xxx xxx-xx-xx'); }); it('returns empty template for empty value', () => { const formatter = new AsYouType('US', metadata); const template = getPhoneTemplate({ formatter, value: '', separator: '-', countryIsoCode: 'US', metadata, format: 'NATIONAL', }); expect(template).toBe(''); }); describe('incomplete US numbers with leading country code', () => { it('1212555 => x (xxx) xxx (no hyphen after parenthesis)', () => { const formatter = new AsYouType('US', metadata); const template = getPhoneTemplate({ formatter, value: '1212555', separator: '-', countryIsoCode: 'US', metadata, format: 'NATIONAL', }); // Should NOT produce 'x (xxx)-xxx' - space after ) must be preserved expect(template).toBe('x (xxx) xxx'); }); it('12125553 => x (xxx) xxx-x (hyphen only before last group)', () => { const formatter = new AsYouType('US', metadata); const template = getPhoneTemplate({ formatter, value: '12125553', separator: '-', countryIsoCode: 'US', metadata, format: 'NATIONAL', }); expect(template).toBe('x (xxx) xxx-x'); }); it('1212 => x (xxx) (incomplete area code)', () => { const formatter = new AsYouType('US', metadata); const template = getPhoneTemplate({ formatter, value: '1212', separator: '-', countryIsoCode: 'US', metadata, format: 'NATIONAL', }); expect(template).toBe('x (xxx)'); }); it('12125 => x (xxx) x', () => { const formatter = new AsYouType('US', metadata); const template = getPhoneTemplate({ formatter, value: '12125', separator: '-', countryIsoCode: 'US', metadata, format: 'NATIONAL', }); expect(template).toBe('x (xxx) x'); }); }); describe('incomplete US numbers with custom separator', () => { it('1212555 with space separator => x (xxx) xxx', () => { const formatter = new AsYouType('US', metadata); const template = getPhoneTemplate({ formatter, value: '1212555', separator: ' ', countryIsoCode: 'US', metadata, format: 'NATIONAL', }); expect(template).toBe('x (xxx) xxx'); }); it('12125553 with space separator => x (xxx) xxx x', () => { const formatter = new AsYouType('US', metadata); const template = getPhoneTemplate({ formatter, value: '12125553', separator: ' ', countryIsoCode: 'US', metadata, format: 'NATIONAL', }); expect(template).toBe('x (xxx) xxx x'); }); }); }); }); ================================================ FILE: projects/phone/src/lib/masks/phone/tests/phone-mask.spec.ts ================================================ import {beforeEach, describe, expect, it} from '@jest/globals'; import { MASKITO_DEFAULT_OPTIONS, type MaskitoOptions, maskitoTransform, } from '@maskito/core'; import metadata from 'libphonenumber-js/min/metadata'; import {maskitoPhoneOptionsGenerator} from '../phone-mask'; describe('Phone (maskitoTransform)', () => { describe('RU number', () => { let options: MaskitoOptions = MASKITO_DEFAULT_OPTIONS; beforeEach(() => { options = maskitoPhoneOptionsGenerator({ countryIsoCode: 'RU', metadata, }); }); it('full number +7 code', () => { expect(maskitoTransform('+79202800155', options)).toBe('+7 920 280-01-55'); }); it('full number 8 code', () => { expect(maskitoTransform('89202800155', options)).toBe('+7 920 280-01-55'); }); it('full number without code', () => { expect(maskitoTransform('9202800155', options)).toBe('+7 920 280-01-55'); }); it('full number with extra chars', () => { expect(maskitoTransform('8 (920) 280-01-55', options)).toBe( '+7 920 280-01-55', ); }); }); describe('non-strict', () => { let options: MaskitoOptions = MASKITO_DEFAULT_OPTIONS; beforeEach(() => { options = maskitoPhoneOptionsGenerator({ metadata, strict: false, countryIsoCode: 'RU', }); }); it('full number +7 code', () => { expect(maskitoTransform('+79202800155', options)).toBe('+7 920 280-01-55'); }); it('full number 8 code', () => { expect(maskitoTransform('89202800155', options)).toBe('+7 920 280-01-55'); }); it('full number with extra chars', () => { expect(maskitoTransform('8 (920) 280-01-55', options)).toBe( '+7 920 280-01-55', ); }); }); describe('National format', () => { describe('US number', () => { let options: MaskitoOptions = MASKITO_DEFAULT_OPTIONS; beforeEach(() => { options = maskitoPhoneOptionsGenerator({ countryIsoCode: 'US', metadata, format: 'NATIONAL', }); }); it('formats digits to national format (xxx) xxx-xxxx', () => { expect(maskitoTransform('2123433355', options)).toBe('(212) 343-3355'); }); it('strips country code from international format', () => { expect(maskitoTransform('+12123433355', options)).toBe('(212) 343-3355'); }); it('handles partial input - area code (library adds closing paren)', () => { expect(maskitoTransform('212', options)).toBe('(212)'); }); it('handles area code plus digit', () => { expect(maskitoTransform('2123', options)).toBe('(212) 3'); }); it('handles formatted input with extra chars', () => { expect(maskitoTransform('(212) 343-3355', options)).toBe( '(212) 343-3355', ); }); }); describe('RU number', () => { let options: MaskitoOptions = MASKITO_DEFAULT_OPTIONS; beforeEach(() => { options = maskitoPhoneOptionsGenerator({ countryIsoCode: 'RU', metadata, format: 'NATIONAL', }); }); it('formats digits to national format', () => { expect(maskitoTransform('9202800155', options)).toBe('920 280-01-55'); }); it('strips country code from international format', () => { expect(maskitoTransform('+79202800155', options)).toBe('920 280-01-55'); }); /** * Note: When input starts with '8', libphonenumber-js interprets it * as part of the number rather than the old Russian trunk prefix. * We test that the national number portion is extracted correctly. */ it('strips alternate country code (8) via parsing', () => { expect(maskitoTransform('89202800155', options)).toBe( '8 (920) 280-01-55', ); }); }); describe('Custom separator', () => { let options: MaskitoOptions = MASKITO_DEFAULT_OPTIONS; beforeEach(() => { options = maskitoPhoneOptionsGenerator({ countryIsoCode: 'US', metadata, format: 'NATIONAL', separator: ' ', }); }); it('uses custom separator in national format', () => { expect(maskitoTransform('2123433355', options)).toBe('(212) 343 3355'); }); }); }); }); ================================================ FILE: projects/phone/src/lib/masks/phone/utils/cut-phone-by-valid-length.ts ================================================ import {type MetadataJson, validatePhoneNumberLength} from 'libphonenumber-js/core'; export function cutPhoneByValidLength({ phone, metadata, }: { phone: string; metadata: MetadataJson; }): string { const validationResult = validatePhoneNumberLength(phone, metadata); if (validationResult === 'TOO_LONG') { return cutPhoneByValidLength({ phone: phone.slice(0, phone.length - 1), metadata, }); } return phone; } ================================================ FILE: projects/phone/src/lib/masks/phone/utils/generate-phone-mask.ts ================================================ import type {MaskitoMaskExpression} from '@maskito/core'; import {TEMPLATE_FILLER} from '../constants'; export function generatePhoneMask({ value, template, prefix, }: { value: string; template: string; prefix: string; }): MaskitoMaskExpression { return [ ...prefix, ...(template ? template .slice(prefix.length) .split('') .map((char) => char === TEMPLATE_FILLER || /\d/.test(char) ? /\d/ : char, ) : Array.from({ length: Math.max(value.length - prefix.length, prefix.length), }).fill(/\d/)), ]; } ================================================ FILE: projects/phone/src/lib/masks/phone/utils/get-country-from-number.ts ================================================ import {AsYouType, type CountryCode, type MetadataJson} from 'libphonenumber-js/core'; export function maskitoGetCountryFromNumber( number: string, metadata: MetadataJson, ): CountryCode | undefined { const formatter = new AsYouType({}, metadata); formatter.input(number); return formatter.getCountry(); } ================================================ FILE: projects/phone/src/lib/masks/phone/utils/get-phone-template.ts ================================================ import { type AsYouType, type CountryCode, formatIncompletePhoneNumber, type MetadataJson, } from 'libphonenumber-js/core'; import type {MaskitoPhoneParams} from '../phone-mask'; export function getPhoneTemplate({ formatter, value, separator, countryIsoCode, metadata, format = 'INTERNATIONAL', }: { formatter: AsYouType; value: string; separator: string; countryIsoCode?: CountryCode; metadata?: MetadataJson; format?: MaskitoPhoneParams['format']; }): string { const isNational = format === 'NATIONAL'; if (isNational && countryIsoCode && metadata) { const normalizedValue = value && !value.startsWith('+') ? `+${value}` : value; formatter.input(normalizedValue.replaceAll(/[^\d+]/g, '')); return getNationalPhoneTemplate({ value: normalizedValue, countryIsoCode, metadata, separator, }); } return getInternationalPhoneTemplate({formatter, value, separator}); } function getInternationalPhoneTemplate({ formatter, value, separator, }: { formatter: AsYouType; value: string; separator: string; }): string { const hasDigitsOrPlus = /[\d+]/.test(value); if (!hasDigitsOrPlus) { return ''; } const normalizedValue = value.startsWith('+') ? value : `+${value}`; formatter.input(normalizedValue.replaceAll(/[^\d+]/g, '')); const initialTemplate = formatter.getTemplate(); const split = initialTemplate.split(' '); // Join first two parts with space, remaining parts with custom separator const template = split.length > 1 ? `${split.slice(0, 2).join(' ')} ${split.slice(2).join(separator)}` : initialTemplate; formatter.reset(); return template.trim(); } function getNationalPhoneTemplate({ value, countryIsoCode, metadata, separator, }: { value: string; countryIsoCode: CountryCode; metadata: MetadataJson; separator: string; }): string { const digitsOnly = value.replaceAll(/\D/g, ''); if (!digitsOnly) { return ''; } const formatted = formatIncompletePhoneNumber(digitsOnly, countryIsoCode, metadata); const template = formatted.replaceAll(/\d/g, 'x'); // Parenthesis-based formats (like US): preserve space after ), only replace dashes if (template.includes(')')) { return template.replaceAll('-', separator); } // Space-separated formats (like FR): join groups after first with separator if (!formatted.includes('-')) { const parts = template.split(' '); return parts.length > 1 ? `${parts[0]} ${parts.slice(1).join(separator)}` : template; } // Dash-separated formats (like RU): swap dashes for custom separator return template.replaceAll('-', separator); } ================================================ FILE: projects/phone/src/lib/masks/phone/utils/index.ts ================================================ export * from './cut-phone-by-valid-length'; export * from './generate-phone-mask'; export * from './get-country-from-number'; export * from './get-phone-template'; export * from './select-template'; ================================================ FILE: projects/phone/src/lib/masks/phone/utils/select-template.ts ================================================ export function selectTemplate({ currentTemplate, newTemplate, currentPhoneLength, newPhoneLength, }: { currentTemplate: string; newTemplate: string; currentPhoneLength: number; newPhoneLength: number; }): string { return newTemplate.length < currentTemplate.length && newPhoneLength > currentPhoneLength ? currentTemplate : newTemplate; } ================================================ FILE: projects/react/.babelrc ================================================ { "presets": [ [ "@nx/react/babel", { "runtime": "automatic" } ] ], "plugins": [] } ================================================ FILE: projects/react/README.md ================================================ # @maskito/react [![npm version](https://img.shields.io/npm/v/@maskito/react.svg)](https://npmjs.com/package/@maskito/react) [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@maskito/react)](https://bundlephobia.com/result?p=@maskito/react)

Maskito logo

DocumentationSubmit an IssueContact Us

> The React-specific library.
It provides a convenient way to use Maskito as a hook. ## How to install ```bash npm i @maskito/{core,react} ``` ================================================ FILE: projects/react/jest.config.ts ================================================ export default { displayName: 'react', preset: '../../jest.preset.js', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], coverageDirectory: '../../coverage/projects/react', }; ================================================ FILE: projects/react/package.json ================================================ { "name": "@maskito/react", "version": "5.2.2", "description": "The React-specific Maskito's library", "keywords": [ "input", "mask", "inputmask", "input-mask", "text-mask", "format", "input-format", "input-formatting", "react" ], "homepage": "https://maskito.dev", "bugs": "https://github.com/taiga-family/maskito/issues", "repository": { "type": "git", "url": "https://github.com/taiga-family/maskito.git" }, "license": "Apache-2.0", "author": { "email": "nextzeddicus@gmail.com", "name": "Georgiy Lunin", "url": "https://github.com/nextZed" }, "contributors": [ { "email": "alexander@inkin.ru", "name": "Alex Inkin" }, { "email": "vladimir.potekh@gmail.com", "name": "Vladimir Potekhin" }, { "email": "nikita.s.barsukov@gmail.com", "name": "Nikita Barsukov" }, { "email": "nextzeddicus@gmail.com", "name": "Georgiy Lunin" } ], "devDependencies": { "@testing-library/react": "16.3.2", "@testing-library/user-event": "14.6.1", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "react": "19.2.5", "react-dom": "19.2.5", "react-test-renderer": "19.2.5" }, "peerDependencies": { "@maskito/core": "^5.2.2", "react": ">=16.8", "react-dom": ">=16.8" } } ================================================ FILE: projects/react/project.json ================================================ { "$schema": "../../node_modules/nx/schemas/project-schema.json", "name": "react", "projectType": "library", "sourceRoot": "projects/react/src", "tags": [], "targets": { "build": { "dependsOn": [ { "dependencies": true, "params": "forward", "target": "build" } ], "executor": "@nx/rollup:rollup", "options": { "assets": [ { "glob": "README.md", "input": "{projectRoot}", "output": "." } ], "compiler": "tsc", "external": "all", "format": ["esm", "cjs"], "main": "{projectRoot}/src/index.ts", "outputPath": "dist/{projectName}", "project": "{projectRoot}/package.json", "tsConfig": "tsconfig.build.json", "useLegacyTypescriptPlugin": false }, "outputs": ["{options.outputPath}"] }, "lint": { "executor": "@nx/eslint:lint", "options": { "lintFilePatterns": ["{projectRoot}/**/*.{ts,tsx,js,jsx}"] }, "outputs": ["{options.outputFile}"] }, "publish": { "dependsOn": [ { "params": "ignore", "target": "build" } ], "executor": "nx:run-commands", "options": { "command": "npm publish ./dist/{projectName} --ignore-scripts" } }, "test": { "executor": "@nx/jest:jest", "options": { "jestConfig": "{projectRoot}/jest.config.ts" }, "outputs": ["{workspaceRoot}/coverage/{projectName}"] } } } ================================================ FILE: projects/react/src/index.ts ================================================ export * from './lib/useMaskito'; ================================================ FILE: projects/react/src/lib/adaptControlledElement.tsx ================================================ import type {MaskitoElement} from '@maskito/core'; /** * React adds `_valueTracker` property to every textfield elements for its internal logic with controlled inputs. * Also, React monkey-patches `value`-setter of the native textfield elements to update state inside its `_valueTracker`. * @see https://github.com/facebook/react/blob/ee76351917106c6146745432a52e9a54a41ee181/packages/react-dom-bindings/src/client/inputValueTracking.js#L12-L19 * * React depends on `_valueTracker` to know if the value was changed to decide: * - should it revert state for controlled input (if its state handler does not update value) * - should it dispatch its synthetic (not native!) `change` event * * When Maskito patches textfield with a valid value (using setter of `value` property), * it also updates `_valueTracker` state and React mistakenly decides that nothing has happened. * React should update `_valueTracker` state by itself! * ___ * @see https://github.com/facebook/react/blob/ee76351917106c6146745432a52e9a54a41ee181/packages/react-dom-bindings/src/client/inputValueTracking.js#L173-L177 */ export function adaptReactControlledElement(element: MaskitoElement): MaskitoElement { const valueSetter = Object.getOwnPropertyDescriptor(getPrototype(element), 'value')?.set; if (!valueSetter) { return element; } const adapter = { set value(value: string) { /** * Mimics exactly what happens when a browser silently changes the value property. * Bypass the React monkey-patching. */ valueSetter.call(element, value); }, }; return new Proxy(element, { get(target, prop: keyof HTMLElement) { const nativeProperty = target[prop]; return typeof nativeProperty === 'function' ? nativeProperty.bind(target) : nativeProperty; }, set(target, prop: keyof HTMLElement, val, receiver) { return Reflect.set(prop in adapter ? adapter : target, prop, val, receiver); }, }); } function getPrototype(element: MaskitoElement): HTMLInputElement | HTMLTextAreaElement | null | undefined { switch (element.nodeName) { case 'INPUT': return globalThis.HTMLInputElement.prototype; case 'TEXTAREA': return globalThis.HTMLTextAreaElement.prototype; default: return null; } } ================================================ FILE: projects/react/src/lib/tests/controlledInput.spec.tsx ================================================ import type {MaskitoOptions} from '@maskito/core'; import {useMaskito} from '@maskito/react'; import {render, type RenderResult} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {type JSX, useCallback, useState} from 'react'; describe('Maskito with React controlled input', () => { let testElement: RenderResult; const setValue = async (user: ReturnType, v: string): Promise => user.type(testElement.getByRole('textbox'), v); const getValue = (): string => (testElement.getByRole('textbox') as HTMLInputElement).value; function TestComponent({ handler, options, }: Readonly<{handler?: (value: string) => void; options: MaskitoOptions}>): JSX.Element { const inputRef = useMaskito({options}); const [value, setValue] = useState(''); return ( { const value = (e.target as HTMLInputElement).value; return handler ? handler(value) : setValue(value); }} /> ); } describe('works with basic mask without processors (only mask expression)', () => { const options: MaskitoOptions = {mask: /^[a-z]$/i}; it('updates value for setState-like action', async () => { testElement = render(); const user = userEvent.setup(); await setValue(user, '1'); // invalid character expect(getValue()).toBe(''); await setValue(user, 'T'); // valid character expect(getValue()).toBe('T'); }); it('does not update value for noop handler of onInput event', async () => { const noop = (): void => {}; testElement = render( , ); const user = userEvent.setup(); await setValue(user, '1'); // invalid character expect(getValue()).toBe(''); await setValue(user, 'T'); // valid character expect(getValue()).toBe(''); }); it('triggers onInput handler on every valid input', async () => { const handler = jest.fn(); testElement = render( , ); const user = userEvent.setup(); await setValue(user, '1'); // invalid character expect(handler).not.toHaveBeenCalled(); await setValue(user, 'T'); // valid character expect(handler).toHaveBeenCalledWith('T'); }); it('state-handler can modify element value', async () => { function App(): JSX.Element { const inputRef = useMaskito({options}); const [value, setValue] = useState(''); const onInputHandler = useCallback( ({value}: HTMLInputElement) => setValue(value.toUpperCase()), [setValue], ); return ( onInputHandler(e.target as HTMLInputElement)} /> ); } testElement = render(); const user = userEvent.setup(); await setValue(user, 't'); expect(getValue()).toBe('T'); }); }); describe('works with complex mask with processors', () => { const options: MaskitoOptions = { mask: /^[a-z]$/i, postprocessors: [({value, selection}) => ({selection, value: value.toUpperCase()})], }; it('updates value for setState-like action', async () => { testElement = render(); const user = userEvent.setup(); await setValue(user, '1'); // invalid character expect(getValue()).toBe(''); await setValue(user, 't'); // valid character expect(getValue()).toBe('T'); }); it('does not update value for noop handler of onInput event', async () => { const noop = (): void => {}; testElement = render( , ); const user = userEvent.setup(); await setValue(user, '1'); // invalid character expect(getValue()).toBe(''); await setValue(user, 't'); // valid character expect(getValue()).toBe(''); }); it('triggers onInput handler on every valid input', async () => { const handler = jest.fn(); testElement = render( , ); const user = userEvent.setup(); await setValue(user, '1'); // invalid character expect(handler).not.toHaveBeenCalled(); await setValue(user, 't'); // valid character expect(handler).toHaveBeenCalledWith('T'); }); it('state-handler can modify element value', async () => { function App(): JSX.Element { const inputRef = useMaskito({options}); const [value, setValue] = useState(''); const onInputHandler = useCallback(({value}: HTMLInputElement) => setValue(`###${value}`), [setValue]); return ( onInputHandler(e.target as HTMLInputElement)} /> ); } testElement = render(); const user = userEvent.setup(); await setValue(user, 't'); expect(getValue()).toBe('###T'); }); }); afterEach(() => { testElement.unmount(); }); }); ================================================ FILE: projects/react/src/lib/tests/elementPredicate.spec.tsx ================================================ import { MASKITO_DEFAULT_ELEMENT_PREDICATE, type MaskitoElementPredicate, maskitoInitialCalibrationPlugin, type MaskitoOptions, } from '@maskito/core'; import {render, type RenderResult, waitFor} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type {JSX} from 'react'; import {useMaskito} from '../useMaskito'; describe('@maskito/react | `elementPredicate` property', () => { const options: MaskitoOptions = { mask: /^\d+$/, plugins: [maskitoInitialCalibrationPlugin()], }; let predicate = MASKITO_DEFAULT_ELEMENT_PREDICATE; const correctPredicate: MaskitoElementPredicate = (host) => host.querySelector('.real-input')!; const wrongPredicate: MaskitoElementPredicate = (host) => host.querySelector('input')!; function TestComponent({elementPredicate = predicate}): JSX.Element { const inputRef = useMaskito({options, elementPredicate}); return (
); } let testElement: RenderResult; const setValue = async (user: ReturnType, v: string): Promise => user.type(testElement.getByPlaceholderText('Enter number'), v); const getValue = (): string => (testElement.getByPlaceholderText('Enter number') as HTMLInputElement).value; afterEach(() => { testElement.unmount(); }); describe('Sync predicate', () => { it('applies mask to the textfield if predicate is correct', async () => { predicate = correctPredicate; testElement = render(); const user = userEvent.setup(); await setValue(user, '123blah45'); expect(getValue()).toBe('12345'); }); it('does not applies mask to the textfield if predicate is incorrect', async () => { predicate = wrongPredicate; testElement = render(); const user = userEvent.setup(); await setValue(user, '123blah45'); expect(getValue()).toBe('123blah45'); }); }); describe('Async predicate', () => { it('predicate resolves in next micro task', async () => { const user = userEvent.setup(); predicate = async (host) => Promise.resolve(correctPredicate(host)); testElement = render(); await setValue(user, '123blah45'); await waitFor(() => { expect(getValue()).toBe('12345'); }); }); it('predicate resolves in next macro task', async () => { const user = userEvent.setup(); predicate = async (host) => new Promise((resolve) => { setTimeout(() => resolve(correctPredicate(host))); }); testElement = render(); await setValue(user, '123blah45'); await waitFor(() => { expect(getValue()).toBe('12345'); }); }); it('predicate resolves in 100ms', async () => { const user = userEvent.setup(); predicate = async (host) => new Promise((resolve) => { setTimeout(() => resolve(correctPredicate(host)), 100); }); testElement = render(); await setValue(user, '123blah45'); await waitFor(() => { expect(getValue()).toBe('12345'); }); }); }); }); ================================================ FILE: projects/react/src/lib/tests/useMaskito.spec.tsx ================================================ import type {MaskitoOptions} from '@maskito/core'; import * as maskitoCore from '@maskito/core'; import {render, type RenderResult, waitFor} from '@testing-library/react'; import userEvent, {type UserEvent} from '@testing-library/user-event'; import type {JSX} from 'react'; import {useMaskito} from '../useMaskito'; const options: MaskitoOptions = { mask: /^\d+(,\d{0,2})?$/, preprocessors: [ ({elementState, data}) => { const {value, selection} = elementState; return { elementState: { selection, value: value.replace('.', ','), }, data: data.replace('.', ','), }; }, ], }; describe('Maskito React package', () => { function TestComponent({onChangeHandler}: Readonly<{onChangeHandler?: (value: string) => void}>): JSX.Element { const inputRef = useMaskito({options}); return ( onChangeHandler?.(e.target.value)} /> ); } function ConditionalInputComponent({showInput}: Readonly<{showInput: boolean}>): JSX.Element { const inputRef = useMaskito({options}); return showInput ? : <>; } let testElement: RenderResult; let user: UserEvent; const type = async (v: string): Promise => user.type(testElement.getByRole('textbox'), v); const getValue = (): string => (testElement.getByRole('textbox') as HTMLInputElement).value; it('should format input value', async () => { testElement = render(); user = userEvent.setup(); await type('12345.6789'); expect(getValue()).toBe('12345,67'); }); it('should trigger onChange event even when Maskito edits value', async () => { const handler = jest.fn(); testElement = render(); user = userEvent.setup(); await type('1.'); expect(handler).toHaveBeenLastCalledWith('1,'); expect(handler).toHaveBeenCalledTimes(2); expect(getValue()).toBe('1,'); }); it('should destroy Maskito instance when input element is removed', async () => { const destroySpy = jest.spyOn(maskitoCore.Maskito.prototype, 'destroy'); testElement = render(); user = userEvent.setup(); await type('1.2'); expect(getValue()).toBe('1,2'); testElement.rerender(); await waitFor(() => { expect(destroySpy).toHaveBeenCalledTimes(1); }); destroySpy.mockRestore(); }); afterEach(() => { testElement.unmount(); }); }); ================================================ FILE: projects/react/src/lib/useIsomorphicLayoutEffect.tsx ================================================ import {useEffect, useLayoutEffect} from 'react'; export const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; ================================================ FILE: projects/react/src/lib/useMaskito.tsx ================================================ import { Maskito, MASKITO_DEFAULT_ELEMENT_PREDICATE, type MaskitoElement, type MaskitoElementPredicate, type MaskitoOptions, } from '@maskito/core'; import {type RefCallback, useCallback, useRef, useState} from 'react'; import {adaptReactControlledElement} from './adaptControlledElement'; import {useIsomorphicLayoutEffect} from './useIsomorphicLayoutEffect'; function isThenable(x: PromiseLike | T): x is PromiseLike { return x && typeof x === 'object' && 'then' in x; } /** * Hook for convenient use of Maskito in React * @description For controlled inputs use `onInput` event * @param options options used for creating Maskito * @param elementPredicate function that can help find nested Input or TextArea * @returns ref callback to pass it in React Element * @example * // To avoid unnecessary hook runs with Maskito recreation pass named variables * // good example ✅ * useMaskito({ options: maskitoOptions, elementPredicate: maskitoPredicate }) * * // bad example ❌ * useMaskito({ options: { mask: /^.*$/ }, elementPredicate: () => e.querySelector('input') }) */ export const useMaskito = ({ options = null, elementPredicate = MASKITO_DEFAULT_ELEMENT_PREDICATE, }: { options?: MaskitoOptions | null; elementPredicate?: MaskitoElementPredicate; } = {}): RefCallback => { const [hostElement, setHostElement] = useState(null); const [element, setElement] = useState(null); const onRefChange: RefCallback = useCallback((node: HTMLElement | null) => { setHostElement(node); }, []); const latestPredicateRef = useRef(elementPredicate); const latestOptionsRef = useRef(options); latestPredicateRef.current = elementPredicate; latestOptionsRef.current = options; useIsomorphicLayoutEffect(() => { if (!hostElement) { return; } const elementOrPromise = elementPredicate(hostElement); if (isThenable(elementOrPromise)) { void elementOrPromise.then((el) => { if (latestPredicateRef.current === elementPredicate && latestOptionsRef.current === options) { setElement(el); } }); } else { setElement(elementOrPromise); } return () => { setElement(null); }; }, [hostElement, elementPredicate, latestPredicateRef, options, latestOptionsRef]); useIsomorphicLayoutEffect(() => { if (!element || !options) { return; } const maskedElement = new Maskito(adaptReactControlledElement(element), options); return () => { maskedElement.destroy(); setElement(null); }; }, [options, element]); return onRefChange; }; ================================================ FILE: projects/vue/README.md ================================================ # @maskito/vue [![npm version](https://img.shields.io/npm/v/@maskito/vue.svg)](https://npmjs.com/package/@maskito/vue) [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@maskito/vue)](https://bundlephobia.com/result?p=@maskito/vue)

Maskito logo

DocumentationSubmit an IssueContact Us

> The Vue-specific library.
It provides a convenient way to use Maskito as a directive. ## How to install ```bash npm i @maskito/{core,vue} ``` ================================================ FILE: projects/vue/jest.config.ts ================================================ export default { displayName: 'vue', preset: '../../jest.preset.js', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], coverageDirectory: '../../coverage/projects/vue', testEnvironmentOptions: {customExportConditions: ['node', 'node-addons']}, }; ================================================ FILE: projects/vue/package.json ================================================ { "name": "@maskito/vue", "version": "5.2.2", "description": "The Vue-specific Maskito's library", "keywords": [ "input", "mask", "inputmask", "input-mask", "text-mask", "format", "input-format", "input-formatting", "vue" ], "homepage": "https://maskito.dev", "bugs": "https://github.com/taiga-family/maskito/issues", "repository": { "type": "git", "url": "https://github.com/taiga-family/maskito.git" }, "license": "Apache-2.0", "author": { "email": "alexander@inkin.ru", "name": "Alex Inkin", "url": "https://twitter.com/waterplea" }, "contributors": [ { "email": "alexander@inkin.ru", "name": "Alex Inkin" }, { "email": "vladimir.potekh@gmail.com", "name": "Vladimir Potekhin" }, { "email": "nikita.s.barsukov@gmail.com", "name": "Nikita Barsukov" }, { "email": "nextzeddicus@gmail.com", "name": "Georgiy Lunin" } ], "devDependencies": { "@vue/test-utils": "2.4.6", "@vue/vue3-jest": "29.2.6", "vue": "3.5.32" }, "peerDependencies": { "@maskito/core": "^5.2.2", "vue": ">=3.0.0" } } ================================================ FILE: projects/vue/project.json ================================================ { "$schema": "../../node_modules/nx/schemas/project-schema.json", "name": "vue", "projectType": "library", "sourceRoot": "projects/vue/src", "tags": [], "targets": { "build": { "dependsOn": [ { "dependencies": true, "params": "forward", "target": "build" } ], "executor": "@nx/rollup:rollup", "options": { "assets": [ { "glob": "README.md", "input": "{projectRoot}", "output": "." } ], "compiler": "tsc", "external": "all", "format": ["esm", "cjs"], "main": "{projectRoot}/src/index.ts", "outputPath": "dist/{projectName}", "project": "{projectRoot}/package.json", "tsConfig": "tsconfig.build.json", "useLegacyTypescriptPlugin": false }, "outputs": ["{options.outputPath}"] }, "lint": { "executor": "@nx/eslint:lint", "options": { "lintFilePatterns": ["{projectRoot}/**/*.{ts,js}"] }, "outputs": ["{options.outputFile}"] }, "publish": { "dependsOn": [ { "params": "ignore", "target": "build" } ], "executor": "nx:run-commands", "options": { "command": "npm publish ./dist/{projectName} --ignore-scripts" } }, "test": { "executor": "@nx/jest:jest", "options": { "jestConfig": "{projectRoot}/jest.config.ts" }, "outputs": ["{workspaceRoot}/coverage/{projectName}"] } } } ================================================ FILE: projects/vue/src/index.ts ================================================ export * from './lib/maskito'; ================================================ FILE: projects/vue/src/lib/maskito.spec.ts ================================================ import {describe, expect, it} from '@jest/globals'; import {maskitoInitialCalibrationPlugin} from '@maskito/core'; import {maskito} from '@maskito/vue'; import {mount} from '@vue/test-utils'; describe('Maskito Vue package', () => { const options = { mask: [ ...Array.from({length: 4}).fill(/\d/), ' ', ...Array.from({length: 4}).fill(/\d/), ' ', ...Array.from({length: 4}).fill(/\d/), ' ', ...Array.from({length: 4}).fill(/\d/), ], plugins: [maskitoInitialCalibrationPlugin()], }; const component = { template: '', directives: {maskito}, data: () => ({ value: '1234567890123456', options, }), }; it('formats text', async () => { const mounted = mount(component); await Promise.resolve(); expect(mounted.find('input').element.value).toBe('1234 5678 9012 3456'); }); }); ================================================ FILE: projects/vue/src/lib/maskito.ts ================================================ import { Maskito, MASKITO_DEFAULT_ELEMENT_PREDICATE, type MaskitoElementPredicate, type MaskitoOptions, } from '@maskito/core'; import type {ObjectDirective} from 'vue'; const teardown = new Map(); const predicates = new Map(); async function update( element: HTMLElement, options: | (MaskitoOptions & { elementPredicate?: MaskitoElementPredicate; }) | null, ): Promise { const predicate = options?.elementPredicate ?? MASKITO_DEFAULT_ELEMENT_PREDICATE; predicates.set(element, predicate); const predicateResult = await predicate(element); if (predicates.get(element) !== predicate) { return; } teardown.get(element)?.destroy(); if (options) { teardown.set(element, new Maskito(predicateResult, options)); } } export const maskito: ObjectDirective< HTMLElement, | (MaskitoOptions & { elementPredicate?: MaskitoElementPredicate; }) | null > = { unmounted: (element) => { teardown.get(element)?.destroy(); teardown.delete(element); predicates.delete(element); }, mounted: async (element, {value}) => update(element, value), updated: async (element, {value, oldValue}) => { if (value !== oldValue) { await update(element, value); } }, }; ================================================ FILE: tsconfig.build.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "declaration": true, "declarationMap": true, "incremental": false, "inlineSources": true, "types": [] }, "angularCompilerOptions": { "compilationMode": "partial" }, "include": [ "**/src/**/*.ts", "**/src/**/*.tsx", "**/src/**/*.d.ts" ], "exclude": ["**/*.spec.ts", "**/*.spec.tsx"] } ================================================ FILE: tsconfig.json ================================================ { "extends": "@taiga-ui/tsconfig", "compilerOptions": { "baseUrl": "./", "jsx": "react-jsx", "outDir": "./dist/out-tsc", "paths": { "@demo/constants": ["projects/demo/src/app/constants/index.ts"], "@maskito/angular": ["projects/angular/src/index.ts"], "@maskito/core": ["projects/core/src/index.ts"], "@maskito/kit": ["projects/kit/src/index.ts"], "@maskito/phone": ["projects/phone/src/index.ts"], "@maskito/react": ["projects/react/src/index.ts"], "@maskito/vue": ["projects/vue/src/index.ts"] }, "typeRoots": ["node_modules/@types"] }, "include": [ "**/*.ts", "**/*.tsx", "**/*.d.ts" ], "references": [ { "path": "./tsconfig.spec.json" } ] } ================================================ FILE: tsconfig.spec.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "types": ["jest", "node"] }, "include": [ "**/*.spec.ts", "**/*.d.ts", "**/*.spec.tsx" ] }