├── .prettierrc ├── .husky ├── pre-commit └── commit-msg ├── .prettierignore ├── commitlint.config.js ├── public └── favicon.ico ├── projects └── ngxpert │ └── input-otp │ ├── schematics │ ├── ng-add │ │ ├── schema.ts │ │ ├── schema.json │ │ ├── index.ts │ │ └── package-config.ts │ └── collection.json │ ├── src │ ├── lib │ │ ├── regexp.ts │ │ ├── sync-timeouts.ts │ │ ├── control-value-signal.ts │ │ ├── components │ │ │ └── input-otp │ │ │ │ ├── input-otp.component.html │ │ │ │ ├── input-otp.component.css │ │ │ │ └── input-otp.component.ts │ │ └── types.ts │ └── public-api.ts │ ├── ng-package.json │ ├── tsconfig.lib.prod.json │ ├── tsconfig.spec.json │ ├── tsconfig.lib.json │ ├── eslint.config.js │ ├── tsconfig.schematics.json │ ├── package.json │ └── README.md ├── src ├── app │ ├── app.component.html │ ├── components │ │ ├── fake-dash │ │ │ ├── fake-dash.component.html │ │ │ └── fake-dash.component.ts │ │ ├── fake-caret │ │ │ ├── fake-caret.component.ts │ │ │ └── fake-caret.component.html │ │ ├── site-footer │ │ │ ├── site-footer.component.ts │ │ │ └── site-footer.component.html │ │ ├── copy-button │ │ │ ├── copy-button.component.html │ │ │ └── copy-button.component.ts │ │ ├── mode-toggle │ │ │ ├── mode-toggle.component.html │ │ │ └── mode-toggle.component.ts │ │ ├── site-header │ │ │ ├── site-header.component.ts │ │ │ └── site-header.component.html │ │ ├── code │ │ │ ├── code.component.html │ │ │ └── code.component.ts │ │ ├── slot │ │ │ ├── slot.component.html │ │ │ └── slot.component.ts │ │ ├── showcase │ │ │ ├── showcase.component.html │ │ │ └── showcase.component.ts │ │ ├── ui │ │ │ └── button │ │ │ │ └── button.component.ts │ │ ├── page-header │ │ │ └── page-header.component.ts │ │ └── icons.ts │ ├── lib │ │ └── utils.ts │ ├── pages │ │ ├── tests │ │ │ ├── copy-paste │ │ │ │ ├── copy-paste.component.html │ │ │ │ └── copy-paste.component.ts │ │ │ ├── with-focus-afterinit │ │ │ │ ├── with-focus-afterinit.component.html │ │ │ │ └── with-focus-afterinit.component.ts │ │ │ ├── with-on-complete │ │ │ │ ├── with-on-complete.component.html │ │ │ │ └── with-on-complete.component.ts │ │ │ ├── base │ │ │ │ ├── base.component.html │ │ │ │ └── base.component.ts │ │ │ ├── inputs │ │ │ │ ├── inputs.component.ts │ │ │ │ └── inputs.component.html │ │ │ └── components │ │ │ │ └── base-input │ │ │ │ ├── base-input.component.ts │ │ │ │ └── base-input.component.html │ │ ├── examples │ │ │ └── main │ │ │ │ ├── utils.ts │ │ │ │ ├── fake-components.ts │ │ │ │ ├── slot.component.ts │ │ │ │ └── main.component.ts │ │ └── home │ │ │ ├── home.component.ts │ │ │ └── home.component.html │ ├── shared │ │ ├── components │ │ │ └── component-w-class │ │ │ │ └── component-w-class.directive.ts │ │ └── pipes │ │ │ ├── safe-html.pipe.ts │ │ │ └── code-highlight.pipe.ts │ ├── app.component.ts │ ├── config │ │ └── site.ts │ ├── app.config.ts │ ├── core │ │ ├── local-storage.service.ts │ │ └── theme-changer.service.ts │ └── app.routes.ts ├── main.ts ├── custom-theme.scss ├── index.html └── styles.css ├── .postcssrc.json ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── cypress.config.ts ├── cypress ├── e2e │ ├── with-focus-afterinit.spec.cy.ts │ ├── with-on-complete.spec.cy.ts │ ├── base.copy-paste.spec.cy.ts │ ├── base.typing.spec.cy.ts │ ├── base.selection.spec.cy.ts │ ├── base.slot.spec.cy.ts │ ├── base.inputs.spec.cy.ts │ ├── base.render.spec.cy.ts │ ├── base.delete-word.spec.cy.ts │ └── permissions-spec.cy.ts └── support │ └── e2e.ts ├── .editorconfig ├── tsconfig.app.json ├── tsconfig.spec.json ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── FUNDING.yml └── workflows │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .releaserc.json ├── .all-contributorsrc ├── CHANGELOG.md ├── LICENSE ├── eslint.config.js ├── tsconfig.json ├── CONTRIBUTING.md ├── package.json ├── .cursor └── rules ├── angular.json ├── CODE_OF_CONDUCT.md └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | # npm test 2 | npx lint-staged 3 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no -- commitlint --edit ${1} 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngxpert/input-otp/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /projects/ngxpert/input-otp/schematics/ng-add/schema.ts: -------------------------------------------------------------------------------- 1 | export interface Schema { 2 | /** Name of the project to target. */ 3 | project: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
4 | 5 |
6 | -------------------------------------------------------------------------------- /.postcssrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": { 3 | "@tailwindcss/postcss": { 4 | "optimize": { 5 | "minify": true 6 | } 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /projects/ngxpert/input-otp/src/lib/regexp.ts: -------------------------------------------------------------------------------- 1 | export const REGEXP_ONLY_DIGITS = '^\\d+$'; 2 | export const REGEXP_ONLY_CHARS = '^[a-zA-Z]+$'; 3 | export const REGEXP_ONLY_DIGITS_AND_CHARS = '^[a-zA-Z0-9]+$'; 4 | -------------------------------------------------------------------------------- /src/app/components/fake-dash/fake-dash.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | -------------------------------------------------------------------------------- /src/app/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/app/pages/tests/copy-paste/copy-paste.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | -------------------------------------------------------------------------------- /src/app/pages/tests/with-focus-afterinit/with-focus-afterinit.component.html: -------------------------------------------------------------------------------- 1 |
4 | 5 |
6 | -------------------------------------------------------------------------------- /projects/ngxpert/input-otp/src/public-api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of input-otp 3 | */ 4 | export * from './lib/components/input-otp/input-otp.component'; 5 | export * from './lib/types'; 6 | export * from './lib/regexp'; 7 | -------------------------------------------------------------------------------- /projects/ngxpert/input-otp/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../../dist/ngxpert/input-otp", 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | } 7 | } -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | 3 | export default defineConfig({ 4 | projectId: 'w78mnp', 5 | e2e: { 6 | baseUrl: 'http://localhost:4200', 7 | testIsolation: false, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /src/app/components/fake-dash/fake-dash.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-fake-dash', 5 | templateUrl: './fake-dash.component.html', 6 | }) 7 | export class FakeDashComponent {} 8 | -------------------------------------------------------------------------------- /src/app/components/fake-caret/fake-caret.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-fake-caret', 5 | templateUrl: './fake-caret.component.html', 6 | }) 7 | export class FakeCaretComponent {} 8 | -------------------------------------------------------------------------------- /src/app/pages/tests/with-on-complete/with-on-complete.component.html: -------------------------------------------------------------------------------- 1 |
4 | 5 |
6 | -------------------------------------------------------------------------------- /src/app/shared/components/component-w-class/component-w-class.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, Input } from '@angular/core'; 2 | 3 | @Directive({ 4 | selector: '[appComponentWClass]', 5 | }) 6 | export class ComponentWClassDirective { 7 | @Input() class = ''; 8 | } 9 | -------------------------------------------------------------------------------- /cypress/e2e/with-focus-afterinit.spec.cy.ts: -------------------------------------------------------------------------------- 1 | beforeEach(() => { 2 | cy.visit('/tests/with-focus-afterinit'); 3 | }); 4 | 5 | describe('With autofocus tests', () => { 6 | it('should autofocus', () => { 7 | cy.get('input').should('be.focused'); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/app/components/fake-caret/fake-caret.component.html: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser'; 2 | import { appConfig } from './app/app.config'; 3 | import { AppComponent } from './app/app.component'; 4 | 5 | bootstrapApplication(AppComponent, appConfig) 6 | .catch((err) => console.error(err)); 7 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { RouterOutlet } from '@angular/router'; 3 | 4 | @Component({ 5 | selector: 'app-root', 6 | imports: [RouterOutlet], 7 | templateUrl: './app.component.html', 8 | }) 9 | export class AppComponent {} 10 | -------------------------------------------------------------------------------- /projects/ngxpert/input-otp/src/lib/sync-timeouts.ts: -------------------------------------------------------------------------------- 1 | export function syncTimeouts(cb: () => void): ReturnType[] { 2 | const t1 = setTimeout(cb, 0); // For faster machines 3 | const t2 = setTimeout(cb, 1_0); 4 | const t3 = setTimeout(cb, 5_0); 5 | return [t1, t2, t3]; 6 | } 7 | -------------------------------------------------------------------------------- /src/app/pages/examples/main/utils.ts: -------------------------------------------------------------------------------- 1 | // Small utility to merge class names. 2 | import { clsx } from 'clsx'; 3 | import { twMerge } from 'tailwind-merge'; 4 | 5 | import type { ClassValue } from 'clsx'; 6 | 7 | export function cn(...inputs: ClassValue[]) { 8 | return twMerge(clsx(inputs)); 9 | } 10 | -------------------------------------------------------------------------------- /src/app/pages/tests/base/base.component.html: -------------------------------------------------------------------------------- 1 |
4 | 5 | 6 |
11 |
12 | -------------------------------------------------------------------------------- /src/app/components/site-footer/site-footer.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { siteConfig } from '../../config/site'; 3 | 4 | @Component({ 5 | selector: 'app-site-footer', 6 | templateUrl: './site-footer.component.html', 7 | }) 8 | export class SiteFooterComponent { 9 | siteConfig = siteConfig; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/pages/tests/base/base.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { TestBaseInputComponent } from '../components/base-input/base-input.component'; 3 | @Component({ 4 | selector: 'app-test-base', 5 | templateUrl: './base.component.html', 6 | imports: [TestBaseInputComponent], 7 | }) 8 | export class TestBaseComponent {} 9 | -------------------------------------------------------------------------------- /cypress/e2e/with-on-complete.spec.cy.ts: -------------------------------------------------------------------------------- 1 | beforeEach(() => { 2 | cy.visit('/tests/with-on-complete'); 3 | }); 4 | 5 | describe('With on complete tests', () => { 6 | it('should change the input value', () => { 7 | cy.get('input') 8 | .type('123456') 9 | .should('have.value', '123456') 10 | .should('be.disabled'); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /projects/ngxpert/input-otp/schematics/collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../../../node_modules/@angular-devkit/schematics/collection-schema.json", 3 | "schematics": { 4 | "ng-add": { 5 | "description": "Add @ngxpert/input-otp to the project.", 6 | "factory": "./ng-add/index#ngAdd", 7 | "schema": "./ng-add/schema.json" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/app/pages/tests/inputs/inputs.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { TestBaseInputComponent } from '../components/base-input/base-input.component'; 3 | 4 | @Component({ 5 | selector: 'app-test-inputs', 6 | templateUrl: './inputs.component.html', 7 | imports: [TestBaseInputComponent], 8 | }) 9 | export class TestInputsComponent {} 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | ij_typescript_use_double_quotes = false 14 | 15 | [*.md] 16 | max_line_length = off 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /src/app/pages/tests/with-focus-afterinit/with-focus-afterinit.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { TestBaseInputComponent } from '../components/base-input/base-input.component'; 3 | 4 | @Component({ 5 | selector: 'app-test-with-focus-afterinit', 6 | templateUrl: './with-focus-afterinit.component.html', 7 | imports: [TestBaseInputComponent], 8 | }) 9 | export class TestWithFocusAfterInitComponent {} 10 | -------------------------------------------------------------------------------- /projects/ngxpert/input-otp/schematics/ng-add/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "$id": "input-otp-ng-add", 4 | "title": "Input OTP ng-add", 5 | "type": "object", 6 | "properties": { 7 | "project": { 8 | "type": "string", 9 | "description": "The name of the project.", 10 | "$default": { 11 | "$source": "projectName" 12 | } 13 | } 14 | }, 15 | "required": [] 16 | } 17 | -------------------------------------------------------------------------------- /src/app/shared/pipes/safe-html.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | import { DomSanitizer } from '@angular/platform-browser'; 3 | 4 | @Pipe({ 5 | name: 'safeHtml', 6 | }) 7 | export class SafeHtmlPipe implements PipeTransform { 8 | constructor(private sanitized: DomSanitizer) {} 9 | transform(value: string | null) { 10 | if (value) { 11 | return this.sanitized.bypassSecurityTrustHtml(value); 12 | } 13 | return value; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /projects/ngxpert/input-otp/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "./tsconfig.lib.json", 5 | "compilerOptions": { 6 | "declarationMap": false 7 | }, 8 | "angularCompilerOptions": { 9 | "compilationMode": "partial" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "./tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "./out-tsc/app", 7 | "types": [] 8 | }, 9 | "files": [ 10 | "src/main.ts" 11 | ], 12 | "include": [ 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "./tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "./out-tsc/spec", 7 | "types": [ 8 | "jasmine" 9 | ] 10 | }, 11 | "include": [ 12 | "src/**/*.spec.ts", 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /cypress/e2e/base.copy-paste.spec.cy.ts: -------------------------------------------------------------------------------- 1 | beforeEach(() => { 2 | cy.visit('/tests/copy-paste'); 3 | }); 4 | 5 | describe('Base tests - Copy paste', { browser: 'electron' }, () => { 6 | it('should be able to copy and paste', () => { 7 | cy.get('[data-testid="copy-container"]').type('123456'); 8 | cy.get('[data-testid="copy-button"]').click(); 9 | cy.get('input').focus(); 10 | cy.document().invoke('execCommand', 'paste'); 11 | cy.get('input').should('have.value', '123456'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/app/components/copy-button/copy-button.component.html: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /src/app/pages/tests/with-on-complete/with-on-complete.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { TestBaseInputComponent } from '../components/base-input/base-input.component'; 3 | 4 | @Component({ 5 | selector: 'app-test-with-on-complete', 6 | templateUrl: './with-on-complete.component.html', 7 | imports: [TestBaseInputComponent], 8 | }) 9 | export class TestWithOnCompleteComponent { 10 | disabled = false; 11 | onComplete = () => { 12 | this.disabled = true; 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /projects/ngxpert/input-otp/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "../../../tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "../../../out-tsc/spec", 7 | "types": [ 8 | "jasmine" 9 | ] 10 | }, 11 | "include": [ 12 | "**/*.spec.ts", 13 | "**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/app/config/site.ts: -------------------------------------------------------------------------------- 1 | export const siteConfig = { 2 | name: 'ngxpert/input-otp', 3 | url: 'https://ngxpert.github.io/input-otp', 4 | ogImage: 'https://ngxpert.github.io/input-otp/og.jpg', 5 | description: 6 | 'One-time password input component for Angular. Accessible. Unstyled. Customizable. Open Source. Build your own OTP form effortlessly.', 7 | links: { 8 | twitter: 'https://twitter.com/shhdharmen', 9 | github: 'https://github.com/ngxpert/input-otp', 10 | }, 11 | }; 12 | 13 | export type SiteConfig = typeof siteConfig; 14 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /projects/ngxpert/input-otp/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "../../../tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "../../../out-tsc/lib", 7 | "declaration": true, 8 | "declarationMap": true, 9 | "inlineSources": true, 10 | "types": [] 11 | }, 12 | "exclude": [ 13 | "**/*.spec.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directories: 10 | - "/" 11 | - "/projects/ngxpert/input-otp" 12 | schedule: 13 | interval: "weekly" 14 | -------------------------------------------------------------------------------- /src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { provideHttpClient, withFetch } from '@angular/common/http'; 2 | import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; 3 | import { provideRouter } from '@angular/router'; 4 | import { routes } from './app.routes'; 5 | import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; 6 | export const appConfig: ApplicationConfig = { 7 | providers: [ 8 | provideZoneChangeDetection({ eventCoalescing: true }), 9 | provideRouter(routes), 10 | provideHttpClient(withFetch()), 11 | provideAnimationsAsync(), 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /cypress/e2e/base.typing.spec.cy.ts: -------------------------------------------------------------------------------- 1 | beforeEach(() => { 2 | cy.visit('/tests/base'); 3 | }); 4 | 5 | describe('Base tests - Typing', () => { 6 | it('should start as empty value', () => { 7 | cy.get('input').should('have.value', ''); 8 | }); 9 | 10 | it('should change the input value', () => { 11 | cy.get('input').type('1').should('have.value', '1'); 12 | 13 | cy.get('input').type('23456').should('have.value', '123456'); 14 | }); 15 | 16 | it('should prevent typing greater than max length', () => { 17 | cy.get('input').type('1234567').should('have.value', '123457'); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/app/components/mode-toggle/mode-toggle.component.html: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /src/app/pages/tests/inputs/inputs.component.html: -------------------------------------------------------------------------------- 1 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | -------------------------------------------------------------------------------- /src/app/components/copy-button/copy-button.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { ButtonDirective } from '../ui/button/button.component'; 3 | import { CdkCopyToClipboard } from '@angular/cdk/clipboard'; 4 | @Component({ 5 | selector: 'app-copy-button', 6 | templateUrl: './copy-button.component.html', 7 | imports: [ButtonDirective, CdkCopyToClipboard], 8 | }) 9 | export class CopyButtonComponent { 10 | @Input() textToCopy = ''; 11 | isCopied = false; 12 | 13 | copied() { 14 | this.isCopied = true; 15 | setTimeout(() => { 16 | this.isCopied = false; 17 | }, 2000); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /projects/ngxpert/input-otp/schematics/ng-add/index.ts: -------------------------------------------------------------------------------- 1 | import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; 2 | import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks'; 3 | import { addPackageToPackageJson } from './package-config'; 4 | import { getPackageVersionFromPackageJson } from './package-config'; 5 | export function ngAdd(): Rule { 6 | return (host: Tree, context: SchematicContext) => { 7 | if (getPackageVersionFromPackageJson(host, '@ngxpert/input-otp') === null) { 8 | addPackageToPackageJson(host, '@ngxpert/input-otp', `~0.0.0-PLACEHOLDER`); 9 | context.addTask(new NodePackageInstallTask()); 10 | } 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/app/pages/examples/main/fake-components.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-fake-dash', 5 | template: ` 6 |
7 |
8 |
9 | `, 10 | }) 11 | export class FakeDashComponent {} 12 | 13 | @Component({ 14 | selector: 'app-fake-caret', 15 | template: ` 16 | 21 | `, 22 | }) 23 | export class FakeCaretComponent {} 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/app/components/mode-toggle/mode-toggle.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject } from '@angular/core'; 2 | import { ButtonDirective } from '../ui/button/button.component'; 3 | import { ThemeWithoutAuto } from '../../core/theme-changer.service'; 4 | import { ThemeChangerService } from '../../core/theme-changer.service'; 5 | 6 | @Component({ 7 | selector: 'app-mode-toggle', 8 | templateUrl: './mode-toggle.component.html', 9 | imports: [ButtonDirective], 10 | }) 11 | export class ModeToggleComponent { 12 | private themeChanger = inject(ThemeChangerService); 13 | isDark = this.themeChanger.isDark; 14 | 15 | changeTheme(theme: ThemeWithoutAuto) { 16 | this.themeChanger.changeTheme(theme); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/app/components/site-header/site-header.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { cn } from '../../lib/utils'; 3 | import { buttonVariants } from '../ui/button/button.component'; 4 | import { IconGithubComponent } from '../icons'; 5 | import { IconTwitterComponent } from '../icons'; 6 | import { siteConfig } from '../../config/site'; 7 | import { ModeToggleComponent } from '../mode-toggle/mode-toggle.component'; 8 | @Component({ 9 | selector: 'app-site-header', 10 | templateUrl: './site-header.component.html', 11 | imports: [IconGithubComponent, IconTwitterComponent, ModeToggleComponent], 12 | }) 13 | export class SiteHeaderComponent { 14 | cn = cn; 15 | buttonVariants = buttonVariants; 16 | siteConfig = siteConfig; 17 | } 18 | -------------------------------------------------------------------------------- /src/app/pages/tests/copy-paste/copy-paste.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ElementRef, viewChild } from '@angular/core'; 2 | import { TestBaseInputComponent } from '../components/base-input/base-input.component'; 3 | @Component({ 4 | selector: 'app-copy-paste', 5 | templateUrl: './copy-paste.component.html', 6 | imports: [TestBaseInputComponent], 7 | }) 8 | export class CopyPasteComponent { 9 | copyContainer = viewChild>('copyContainer'); 10 | 11 | copy() { 12 | // get the text content of the container 13 | const text = this.copyContainer()?.nativeElement.textContent; 14 | if (!text) { 15 | return; 16 | } 17 | // copy the text to the clipboard 18 | navigator.clipboard.writeText(text); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /src/app/shared/pipes/code-highlight.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | import { from, Observable, of } from 'rxjs'; 3 | import { codeToHtml } from 'shiki/bundle/web'; 4 | 5 | @Pipe({ 6 | name: 'codeHighlight', 7 | standalone: true, 8 | }) 9 | export class CodeHighlightPipe implements PipeTransform { 10 | transform( 11 | value: string | null | undefined, 12 | language = 'typescript', 13 | ): Observable { 14 | return value 15 | ? from( 16 | codeToHtml(value, { 17 | lang: language, 18 | themes: { 19 | light: 'one-light', 20 | dark: 'one-dark-pro', 21 | }, 22 | defaultColor: false, 23 | }), 24 | ) 25 | : of(''); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /projects/ngxpert/input-otp/eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const tseslint = require("typescript-eslint"); 3 | const rootConfig = require("../../../eslint.config.js"); 4 | 5 | module.exports = tseslint.config( 6 | ...rootConfig, 7 | { 8 | files: ["**/*.ts"], 9 | rules: { 10 | "@angular-eslint/directive-selector": [ 11 | "error", 12 | { 13 | type: "attribute", 14 | prefix: "", 15 | style: "camelCase", 16 | }, 17 | ], 18 | "@angular-eslint/component-selector": [ 19 | "error", 20 | { 21 | type: "element", 22 | prefix: "", 23 | style: "kebab-case", 24 | }, 25 | ], 26 | }, 27 | }, 28 | { 29 | files: ["**/*.html"], 30 | rules: {}, 31 | }, 32 | ); 33 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | // import './commands'; 18 | 19 | describe('Open dev app', () => { 20 | it('should visit dev app', () => { 21 | cy.visit('/'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "+([0-9])?(.{+([0-9]),x}).x", 4 | "master", 5 | "main", 6 | "next", 7 | "next-major", 8 | { "name": "beta", "prerelease": true }, 9 | { "name": "development", "prerelease": true }, 10 | { "name": "alpha", "prerelease": true } 11 | ], 12 | "preset": "angular", 13 | "plugins": [ 14 | "@semantic-release/commit-analyzer", 15 | "@semantic-release/release-notes-generator", 16 | "@semantic-release/changelog", 17 | [ 18 | "@semantic-release/npm", 19 | { 20 | "pkgRoot": "./dist/ngxpert/input-otp", 21 | "tarballDir": "dist" 22 | } 23 | ], 24 | [ 25 | "@semantic-release/github", 26 | { 27 | "assets": ["dist/*.tgz"] 28 | } 29 | ], 30 | "@semantic-release/git" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /projects/ngxpert/input-otp/tsconfig.schematics.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "lib": ["es2018", "dom"], 5 | "declaration": true, 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "noEmitOnError": true, 9 | "noFallthroughCasesInSwitch": true, 10 | "noImplicitAny": true, 11 | "noImplicitThis": true, 12 | "noUnusedParameters": true, 13 | "noUnusedLocals": true, 14 | "rootDir": "schematics", 15 | "outDir": "../../../dist/ngxpert/input-otp/schematics", 16 | "skipDefaultLibCheck": true, 17 | "skipLibCheck": true, 18 | "sourceMap": true, 19 | "strictNullChecks": true, 20 | "target": "es6", 21 | "types": ["jasmine", "node"] 22 | }, 23 | "include": ["schematics/**/*"], 24 | "exclude": ["schematics/*/files/**/*"] 25 | } 26 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [shhdharmen] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | polar: # Replace with a single Polar username 14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] -------------------------------------------------------------------------------- /src/app/components/code/code.component.html: -------------------------------------------------------------------------------- 1 |
2 | 9 | @for (file of files; track file.fileName) { 10 | 11 | {{ file.fileName }} 12 |
13 | 14 | 15 | 16 |
21 |
22 |
23 | } 24 |
25 |
26 | -------------------------------------------------------------------------------- /projects/ngxpert/input-otp/src/lib/control-value-signal.ts: -------------------------------------------------------------------------------- 1 | import { computed, type Injector, type Signal, untracked } from '@angular/core'; 2 | import { toSignal } from '@angular/core/rxjs-interop'; 3 | import { FormControl } from '@angular/forms'; 4 | import { startWith } from 'rxjs'; 5 | 6 | /** 7 | * Returns a signal that contains the value of a control (or a form). 8 | * @param control 9 | * @param injector 10 | */ 11 | export function getControlValueSignal( 12 | control: FormControl, 13 | injector: Injector, 14 | ): Signal { 15 | const valueChanges = computed(() => { 16 | return untracked(() => 17 | toSignal(control.valueChanges.pipe(startWith(control.value)), { 18 | injector, 19 | initialValue: undefined, 20 | }), 21 | ); 22 | }); 23 | 24 | return computed(() => valueChanges()()); 25 | } 26 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "input-otp", 3 | "projectOwner": "ngxpert", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": ["README.md"], 7 | "imageSize": 100, 8 | "commit": true, 9 | "commitConvention": "angular", 10 | "contributors": [ 11 | { 12 | "login": "shhdharmen", 13 | "name": "Dharmen Shah", 14 | "avatar_url": "https://avatars.githubusercontent.com/u/6831283?v=4", 15 | "profile": "https://github.com/shhdharmen", 16 | "contributions": [ 17 | "a11y", 18 | "question", 19 | "bug", 20 | "code", 21 | "content", 22 | "doc", 23 | "example", 24 | "maintenance", 25 | "projectManagement", 26 | "review", 27 | "test" 28 | ] 29 | } 30 | ], 31 | "contributorsPerLine": 7, 32 | "linkToUsage": true 33 | } 34 | 35 | -------------------------------------------------------------------------------- /cypress/e2e/base.selection.spec.cy.ts: -------------------------------------------------------------------------------- 1 | beforeEach(() => { 2 | cy.visit('/tests/base'); 3 | }); 4 | 5 | describe('Base tests - Selections', () => { 6 | it('should replace selected char if another is pressed', () => { 7 | cy.get('input').as('input'); 8 | 9 | cy.get('@input').type('123'); 10 | cy.get('@input').type('{leftarrow}'); 11 | cy.get('@input').type('1'); 12 | cy.get('@input').should('have.value', '121'); 13 | }); 14 | it('should replace multi-selected chars if another is pressed', () => { 15 | cy.get('input').as('input'); 16 | cy.get('@input').type('123456'); 17 | cy.get('@input') 18 | .then(($el) => $el.get(0).setSelectionRange(3, 6)) 19 | .type('1'); 20 | cy.get('@input').should('have.value', '1231'); 21 | }); 22 | it('should replace last char if another one is pressed', () => { 23 | cy.get('input').as('input'); 24 | 25 | cy.get('@input').type('1234567'); 26 | cy.get('@input').should('have.value', '123457'); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /src/app/core/local-storage.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | @Injectable({ 4 | providedIn: 'root', 5 | }) 6 | export class LocalStorageService { 7 | /** 8 | * Save data to localStorage 9 | * @param key Storage key 10 | * @param data Data to store 11 | */ 12 | save(key: string, data: T): void { 13 | localStorage.setItem(key, JSON.stringify(data)); 14 | } 15 | 16 | /** 17 | * Load data from localStorage 18 | * @param key Storage key 19 | * @returns Parsed data or null if not found 20 | */ 21 | load(key: string): T | null { 22 | const data = localStorage.getItem(key); 23 | if (!data) return null; 24 | return JSON.parse(data); 25 | } 26 | 27 | /** 28 | * Remove data from localStorage 29 | * @param key Storage key 30 | */ 31 | remove(key: string): void { 32 | localStorage.removeItem(key); 33 | } 34 | 35 | /** 36 | * Clear all data from localStorage 37 | */ 38 | clear(): void { 39 | localStorage.clear(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.0.3](https://github.com/ngxpert/input-otp/compare/v1.0.2...v1.0.3) (2025-02-19) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * remove consoles, docs, fix examples ([54b8377](https://github.com/ngxpert/input-otp/commit/54b83772d77192a08f0cc8862e0bb63db30e973d)) 7 | 8 | ## [1.0.2](https://github.com/ngxpert/input-otp/compare/v1.0.1...v1.0.2) (2025-02-19) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * missing schema file for ng-add ([94592d4](https://github.com/ngxpert/input-otp/commit/94592d42a13a99626f45aef30ae82066fe90eea4)) 14 | 15 | ## [1.0.1](https://github.com/ngxpert/input-otp/compare/v1.0.0...v1.0.1) (2025-02-19) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * docs: hide features for now ([b5e843d](https://github.com/ngxpert/input-otp/commit/b5e843d0d3e28f3db2731a4dc8f947eb4b8750af)) 21 | 22 | ## [1.0.1-beta.1](https://github.com/ngxpert/input-otp/compare/v1.0.0...v1.0.1-beta.1) (2025-02-19) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | * docs: hide features for now ([b5e843d](https://github.com/ngxpert/input-otp/commit/b5e843d0d3e28f3db2731a4dc8f947eb4b8750af)) 28 | -------------------------------------------------------------------------------- /cypress/e2e/base.slot.spec.cy.ts: -------------------------------------------------------------------------------- 1 | beforeEach(() => { 2 | cy.visit('/tests/base'); 3 | }); 4 | 5 | describe('Base tests - Slots', () => { 6 | it('should expose the slot value', () => { 7 | cy.get('input').as('input'); 8 | 9 | cy.get('@input').type('1'); 10 | cy.get('@input').should('have.value', '1'); 11 | 12 | cy.get('[data-testid="slot-0"]').should('have.attr', 'data-test-char', '1'); 13 | 14 | cy.get('[data-testid="slot-1"]').should('not.have.attr', 'data-test-char'); 15 | 16 | cy.get('@input').type('23456'); 17 | cy.get('@input').should('have.value', '123456'); 18 | 19 | cy.get('[data-testid="slot-1"]').should('have.attr', 'data-test-char', '2'); 20 | 21 | cy.get('[data-testid="slot-2"]').should('have.attr', 'data-test-char', '3'); 22 | 23 | cy.get('[data-testid="slot-3"]').should('have.attr', 'data-test-char', '4'); 24 | 25 | cy.get('[data-testid="slot-4"]').should('have.attr', 'data-test-char', '5'); 26 | 27 | cy.get('[data-testid="slot-5"]').should('have.attr', 'data-test-char', '6'); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/app/components/slot/slot.component.html: -------------------------------------------------------------------------------- 1 |
10 |
18 | @if (char) { 19 |
{{ char }}
20 | } @else { 21 | {{ " " }} 22 | } 23 |
24 | 25 | @if (hasFakeCaret) { 26 |
27 | 28 |
29 | } 30 |
31 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Make a Release and Deploy on GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Use Node.js 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: '22.x' 18 | 19 | - name: Install dependencies 20 | env: 21 | HUSKY: 0 22 | run: npm ci 23 | 24 | - name: Build library 25 | run: npm run build:lib 26 | 27 | - name: Build app 28 | run: npm run build -- --base-href "/input-otp/" 29 | 30 | - name: Release 31 | env: 32 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 34 | HUSKY: 0 35 | run: npx semantic-release 36 | 37 | - name: Deploy to GitHub Pages 38 | env: 39 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | run: npx angular-cli-ghpages --name="mr. Dharmen's Bot" --email=shhdharmen@gmail.com --dir=dist/input-otp/browser 41 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Dharmen Shah 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/app/components/slot/slot.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { FakeCaretComponent } from '../fake-caret/fake-caret.component'; 3 | import { NgClass } from '@angular/common'; 4 | import { SlotProps } from '@ngxpert/input-otp'; 5 | 6 | @Component({ 7 | selector: 'app-slot', 8 | imports: [FakeCaretComponent, NgClass], 9 | templateUrl: './slot.component.html', 10 | }) 11 | export class SlotComponent implements SlotProps { 12 | @Input() isActive = false; 13 | @Input() char: string | null = null; 14 | @Input() placeholderChar: string | null = null; 15 | @Input() hasFakeCaret = false; 16 | @Input() first = false; 17 | @Input() last = false; 18 | private _animateIdx: number | undefined; 19 | willAnimateChar = false; 20 | willAnimateCaret = false; 21 | @Input() 22 | get animateIdx(): number | undefined { 23 | return this._animateIdx; 24 | } 25 | 26 | set animateIdx(value: number | undefined) { 27 | this._animateIdx = value; 28 | this.willAnimateChar = 29 | this._animateIdx !== undefined && this._animateIdx < 2; 30 | this.willAnimateCaret = this._animateIdx === 2; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const eslint = require("@eslint/js"); 3 | const tseslint = require("typescript-eslint"); 4 | const angular = require("angular-eslint"); 5 | 6 | module.exports = tseslint.config( 7 | { 8 | files: ["**/*.ts"], 9 | extends: [ 10 | eslint.configs.recommended, 11 | ...tseslint.configs.recommended, 12 | ...tseslint.configs.stylistic, 13 | ...angular.configs.tsRecommended, 14 | ], 15 | processor: angular.processInlineTemplates, 16 | rules: { 17 | "@angular-eslint/directive-selector": [ 18 | "error", 19 | { 20 | type: "attribute", 21 | prefix: "app", 22 | style: "camelCase", 23 | }, 24 | ], 25 | "@angular-eslint/component-selector": [ 26 | "error", 27 | { 28 | type: "element", 29 | prefix: "app", 30 | style: "kebab-case", 31 | }, 32 | ], 33 | }, 34 | }, 35 | { 36 | files: ["**/*.html"], 37 | extends: [ 38 | ...angular.configs.templateRecommended, 39 | ...angular.configs.templateAccessibility, 40 | ], 41 | rules: {}, 42 | } 43 | ); 44 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "compileOnSave": false, 5 | "compilerOptions": { 6 | "paths": { 7 | "@ngxpert/input-otp": ["./dist/ngxpert/input-otp"], 8 | "@lib/*": ["./src/app/lib/*"] 9 | }, 10 | "outDir": "./dist/out-tsc", 11 | "strict": true, 12 | "noImplicitOverride": true, 13 | "noPropertyAccessFromIndexSignature": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "skipLibCheck": true, 17 | "isolatedModules": true, 18 | "esModuleInterop": true, 19 | "experimentalDecorators": true, 20 | "moduleResolution": "bundler", 21 | "importHelpers": true, 22 | "target": "ES2022", 23 | "module": "ES2022" 24 | }, 25 | "angularCompilerOptions": { 26 | "enableI18nLegacyMessageIdFormat": false, 27 | "strictInjectionParameters": true, 28 | "strictInputAccessModifiers": true, 29 | "strictTemplates": true 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /cypress/e2e/base.inputs.spec.cy.ts: -------------------------------------------------------------------------------- 1 | beforeEach(() => { 2 | cy.visit('/tests/inputs'); 3 | }); 4 | 5 | describe('Inputs tests', () => { 6 | it('should receive props accordingly', () => { 7 | const input1 = cy.get('[data-testid="input-otp-1"] input'); 8 | input1.should('be.disabled'); 9 | 10 | const input2 = cy.get('[data-testid="input-otp-2"] input'); 11 | input2.should('have.attr', 'inputmode', 'numeric'); 12 | 13 | const input3 = cy.get('[data-testid="input-otp-3"] input'); 14 | input3.should('have.attr', 'inputmode', 'text'); 15 | 16 | const container4 = cy.get( 17 | '[data-testid="input-otp-4"] [data-input-otp-container]', 18 | ); 19 | container4.should('have.class', 'testclassname'); 20 | const input5 = cy.get('[data-testid="input-otp-5"] input'); 21 | input5.should('have.attr', 'maxlength', '3'); 22 | 23 | const input6 = cy.get('[data-testid="input-otp-6"] input'); 24 | input6.should('have.attr', 'id', 'testid'); 25 | input6.should('have.attr', 'name', 'testname'); 26 | 27 | const input7 = cy.get('[data-testid="input-otp-7"] input'); 28 | input7.should('have.attr', 'pattern', ' '); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/custom-theme.scss: -------------------------------------------------------------------------------- 1 | // Custom Theming for Angular Material 2 | // For more information: https://material.angular.io/guide/theming 3 | @use "@angular/material" as mat; 4 | 5 | html { 6 | color-scheme: light; 7 | @include mat.theme( 8 | ( 9 | color: ( 10 | primary: mat.$azure-palette, 11 | tertiary: mat.$blue-palette, 12 | ), 13 | typography: InterVariable, 14 | density: 0, 15 | ) 16 | ); 17 | .shiki, 18 | .shiki span { 19 | color: var(--shiki-light); 20 | background-color: var(--shiki-light-bg); 21 | /* Optional, if you also want font styles */ 22 | font-style: var(--shiki-light-font-style); 23 | font-weight: var(--shiki-light-font-weight); 24 | text-decoration: var(--shiki-light-text-decoration); 25 | } 26 | } 27 | 28 | .dark { 29 | color-scheme: dark; 30 | 31 | .shiki, 32 | .shiki span { 33 | color: var(--shiki-dark); 34 | background-color: var(--shiki-dark-bg); 35 | /* Optional, if you also want font styles */ 36 | font-style: var(--shiki-dark-font-style); 37 | font-weight: var(--shiki-dark-font-weight); 38 | text-decoration: var(--shiki-dark-text-decoration); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /projects/ngxpert/input-otp/src/lib/components/input-otp/input-otp.component.html: -------------------------------------------------------------------------------- 1 |
7 | 8 |
15 | 35 |
36 |
37 | -------------------------------------------------------------------------------- /cypress/e2e/base.render.spec.cy.ts: -------------------------------------------------------------------------------- 1 | beforeEach(() => { 2 | cy.visit('/tests/base'); 3 | }); 4 | 5 | describe('Base tests - Render', () => { 6 | it('should expose focus flags', () => { 7 | cy.get('input').as('input'); 8 | cy.get('[data-testid="input-otp-renderer"]').as('renderer'); 9 | 10 | cy.get('@input').focus(); 11 | cy.get('@renderer').should( 12 | 'have.attr', 13 | 'data-test-render-is-focused', 14 | 'true', 15 | ); 16 | 17 | cy.get('@input').blur(); 18 | cy.get('@renderer').should('not.have.attr', 'data-test-render-is-focused'); 19 | }); 20 | it('should expose hover flags', async () => { 21 | cy.get('[data-testid="input-otp-renderer"]').as('renderer'); 22 | cy.get('@renderer').should('not.have.attr', 'data-test-render-is-hovering'); 23 | 24 | cy.get('@renderer').then((el) => { 25 | const rect = el.get(0).getBoundingClientRect(); 26 | cy.get('body').trigger( 27 | 'mouseenter', 28 | rect.x + rect.width / 2, 29 | rect.y + rect.height / 2, 30 | ); 31 | }); 32 | 33 | cy.get('@renderer').should( 34 | 'have.attr', 35 | 'data-test-render-is-hovering', 36 | 'true', 37 | ); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /projects/ngxpert/input-otp/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import { InputSignal, OutputEmitterRef } from '@angular/core'; 2 | 3 | export interface SlotProps { 4 | isActive: boolean; 5 | char: string | null; 6 | placeholderChar: string | null; 7 | hasFakeCaret: boolean; 8 | } 9 | 10 | export interface InputOTPInputsOutputs { 11 | maxLength: InputSignal; 12 | // TODO: Add support for textAlign 13 | textAlign?: InputSignal<'left' | 'center' | 'right'>; 14 | pattern?: InputSignal; 15 | placeholder?: InputSignal; 16 | inputMode?: InputSignal<'numeric' | 'text'>; 17 | disabled?: InputSignal; 18 | autoComplete?: InputSignal; 19 | // TODO: Add support for password manager badge 20 | pushPasswordManagerStrategy?: InputSignal<'increase-width' | 'none'>; 21 | containerClass?: InputSignal; 22 | complete: OutputEmitterRef; 23 | } 24 | 25 | export interface OTPSlot { 26 | char: string | null; 27 | placeholderChar: string | null; 28 | isActive: boolean; 29 | hasFakeCaret: boolean; 30 | } 31 | 32 | export interface OTPRenderContext { 33 | slots: OTPSlot[]; 34 | isFocused: boolean; 35 | isHovering: boolean; 36 | } 37 | -------------------------------------------------------------------------------- /projects/ngxpert/input-otp/src/lib/components/input-otp/input-otp.component.css: -------------------------------------------------------------------------------- 1 | [data-input-otp]::selection { 2 | background: transparent !important; 3 | color: transparent !important; 4 | } 5 | 6 | [data-input-otp]:autofill { 7 | background: transparent !important; 8 | color: transparent !important; 9 | border-color: transparent !important; 10 | opacity: 0 !important; 11 | box-shadow: none !important; 12 | -webkit-box-shadow: none !important; 13 | -webkit-text-fill-color: transparent !important; 14 | } 15 | 16 | [data-input-otp]:-webkit-autofill { 17 | background: transparent !important; 18 | color: transparent !important; 19 | border-color: transparent !important; 20 | opacity: 0 !important; 21 | box-shadow: none !important; 22 | -webkit-box-shadow: none !important; 23 | -webkit-text-fill-color: transparent !important; 24 | } 25 | 26 | @supports (-webkit-touch-callout: none) { 27 | [data-input-otp] { 28 | letter-spacing: -0.6em !important; 29 | font-weight: 100 !important; 30 | font-stretch: ultra-condensed; 31 | font-optical-sizing: none !important; 32 | left: -1px !important; 33 | right: 1px !important; 34 | } 35 | } 36 | 37 | [data-input-otp] + * { 38 | pointer-events: all !important; 39 | } -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | @ngxpert/input-otp 6 | 7 | 8 | 9 | 10 | 11 | 12 | 16 | 20 | 24 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/app/pages/tests/components/base-input/base-input.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AfterViewInit, 3 | Component, 4 | EventEmitter, 5 | Input, 6 | Output, 7 | booleanAttribute, 8 | numberAttribute, 9 | viewChild, 10 | } from '@angular/core'; 11 | import { FormsModule } from '@angular/forms'; 12 | import { InputOTPComponent } from '@ngxpert/input-otp'; 13 | import { cn } from '@lib/utils'; 14 | 15 | @Component({ 16 | selector: 'app-base-input', 17 | templateUrl: './base-input.component.html', 18 | imports: [InputOTPComponent, FormsModule], 19 | }) 20 | export class TestBaseInputComponent implements AfterViewInit { 21 | value = ''; 22 | cn = cn; 23 | 24 | @Input({ transform: booleanAttribute }) disabled = false; 25 | @Input() inputMode: 'numeric' | 'text' = 'numeric'; 26 | @Input({ transform: numberAttribute }) maxLength = 6; 27 | @Input() pattern?: string | RegExp; 28 | @Input() placeholder?: string; 29 | @Input() containerClass?: string; 30 | @Input() id?: string; 31 | @Input() name?: string; 32 | @Input({ transform: booleanAttribute }) focusAfterInit = false; 33 | @Output() complete = new EventEmitter(); 34 | 35 | otpInput = viewChild('otpInput'); 36 | 37 | ngAfterViewInit() { 38 | if (this.focusAfterInit) { 39 | this.otpInput()?.inputRef()?.nativeElement.focus(); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/app/components/site-footer/site-footer.component.html: -------------------------------------------------------------------------------- 1 | 43 | -------------------------------------------------------------------------------- /src/app/pages/examples/main/slot.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { FakeCaretComponent } from './fake-components'; 3 | import { cn } from './utils'; 4 | 5 | @Component({ 6 | selector: 'app-slot', 7 | template: ` 8 |
23 | @if (char) { 24 |
{{ char }}
25 | } @else { 26 | {{ ' ' }} 27 | } 28 | @if (hasFakeCaret) { 29 | 30 | } 31 |
32 | `, 33 | imports: [FakeCaretComponent], 34 | }) 35 | export class SlotComponent { 36 | @Input() isActive = false; 37 | @Input() char: string | null = null; 38 | @Input() placeholderChar: string | null = null; 39 | @Input() hasFakeCaret = false; 40 | @Input() first = false; 41 | @Input() last = false; 42 | cn = cn; 43 | } 44 | -------------------------------------------------------------------------------- /projects/ngxpert/input-otp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ngxpert/input-otp", 3 | "version": "0.0.0-development", 4 | "description": "One-time password input component for Angular.", 5 | "keywords": [ 6 | "angular", 7 | "otp", 8 | "input", 9 | "accessible" 10 | ], 11 | "author": { 12 | "name": "ngxpert", 13 | "url": "https://github.com/ngxpert" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/ngxpert/input-otp" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/ngxpert/input-otp/issues" 21 | }, 22 | "license": "MIT", 23 | "scripts": { 24 | "build": "tsc -p tsconfig.schematics.json", 25 | "postpublish": "cpx package.json ../../../../../projects/ngxpert/input-otp/ && cpx npm-shrinkwrap.json ../../../../../projects/ngxpert/input-otp/", 26 | "postbuild": "copyfiles schematics/*/schema.json schematics/*/files/** schematics/collection.json ../../../dist/ngxpert/input-otp/" 27 | }, 28 | "peerDependencies": { 29 | "@angular/common": ">=19.0.0", 30 | "@angular/core": ">=19.0.0", 31 | "@angular/forms": ">=19.0.0" 32 | }, 33 | "dependencies": { 34 | "tslib": "^2.3.0" 35 | }, 36 | "sideEffects": false, 37 | "schematics": "./schematics/collection.json", 38 | "ng-add": { 39 | "save": "dependencies" 40 | }, 41 | "publishConfig": { 42 | "access": "public" 43 | } 44 | } -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test, Build and Publish a beta version 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: '22.x' 20 | 21 | - uses: actions/cache@v4 22 | id: npm-cache 23 | with: 24 | # The Cypress binary is saved within the `~/.cache` folder. 25 | path: | 26 | node_modules 27 | ~/.cache 28 | key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }} 29 | restore-keys: ${{ runner.os }}-npm 30 | 31 | - name: Install dependencies 32 | if: steps.npm-cache.outputs.cache-hit != 'true' 33 | env: 34 | HUSKY: 0 35 | run: npm ci 36 | 37 | - name: Lint 38 | run: npm run lint -- @ngxpert/input-otp 39 | 40 | - name: Build library 41 | run: npm run build:lib 42 | 43 | - name: Build 44 | run: npm run build 45 | 46 | - name: Test 47 | run: npm run test 48 | 49 | - name: Release 50 | env: 51 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 52 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 53 | HUSKY: 0 54 | run: npx semantic-release --debug -------------------------------------------------------------------------------- /src/app/pages/tests/components/base-input/base-input.component.html: -------------------------------------------------------------------------------- 1 | 18 |
28 | @for (slot of otpInput.slots(); track $index) { 29 |
42 | {{ slot.char !== null ? slot.char : " " }} 43 |
44 | } 45 |
46 |
47 | -------------------------------------------------------------------------------- /cypress/e2e/base.delete-word.spec.cy.ts: -------------------------------------------------------------------------------- 1 | beforeEach(() => { 2 | cy.visit('/tests/base'); 3 | }); 4 | 5 | describe('Backspace', () => { 6 | it('should backspace previous word (even if there is not a selected character)', () => { 7 | const input = cy.get('input'); 8 | 9 | input.type('1234'); 10 | input.should('have.value', '1234'); 11 | 12 | input.clear(); 13 | input.should('have.value', ''); 14 | }); 15 | it('should backspace selected char', () => { 16 | const input = cy.get('input'); 17 | 18 | input.type('123456'); 19 | input.should('have.value', '123456'); 20 | 21 | input.type('{leftArrow}'); 22 | input.type('{leftArrow}'); 23 | input.type(`{backspace}`); 24 | 25 | input.should('have.value', '12356'); 26 | }); 27 | }); 28 | describe('Delete', () => { 29 | it('should forward-delete character when pressing delete', () => { 30 | const input = cy.get('input'); 31 | 32 | input.type('123456'); 33 | input.should('have.value', '123456'); 34 | 35 | input.type('{del}'); 36 | input.should('have.value', '12345'); 37 | 38 | input.type('{leftArrow}'); 39 | input.type('{leftArrow}'); 40 | input.type('{leftArrow}'); 41 | input.type('{leftArrow}'); 42 | input.type('{leftArrow}'); 43 | input.type('{del}'); 44 | input.should('have.value', '2345'); 45 | 46 | input.type('{rightArrow}'); 47 | input.type('{rightArrow}'); 48 | input.type('{del}'); 49 | input.should('have.value', '235'); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | import { HomeComponent } from './pages/home/home.component'; 3 | import { ExamplesMainComponent } from './pages/examples/main/main.component'; 4 | import { TestBaseComponent } from './pages/tests/base/base.component'; 5 | import { TestInputsComponent } from './pages/tests/inputs/inputs.component'; 6 | import { TestWithFocusAfterInitComponent } from './pages/tests/with-focus-afterinit/with-focus-afterinit.component'; 7 | import { TestWithOnCompleteComponent } from './pages/tests/with-on-complete/with-on-complete.component'; 8 | import { CopyPasteComponent } from './pages/tests/copy-paste/copy-paste.component'; 9 | 10 | export const routes: Routes = [ 11 | { 12 | path: '', 13 | redirectTo: 'home', 14 | pathMatch: 'full', 15 | }, 16 | { 17 | path: 'home', 18 | component: HomeComponent, 19 | }, 20 | { 21 | path: 'examples', 22 | component: ExamplesMainComponent, 23 | }, 24 | { 25 | path: 'tests', 26 | children: [ 27 | { 28 | path: 'base', 29 | component: TestBaseComponent, 30 | }, 31 | { 32 | path: 'inputs', 33 | component: TestInputsComponent, 34 | }, 35 | { 36 | path: 'with-focus-afterinit', 37 | component: TestWithFocusAfterInitComponent, 38 | }, 39 | { 40 | path: 'with-on-complete', 41 | component: TestWithOnCompleteComponent, 42 | }, 43 | { 44 | path: 'copy-paste', 45 | component: CopyPasteComponent, 46 | }, 47 | ], 48 | }, 49 | ]; 50 | -------------------------------------------------------------------------------- /src/app/components/showcase/showcase.component.html: -------------------------------------------------------------------------------- 1 |
5 | 16 |
17 | @for ( 18 | slot of otp.slots().slice(0, 3); 19 | track $index; 20 | let first = $first; 21 | let last = $last 22 | ) { 23 | 32 | } 33 | 34 | 35 | 36 | @for ( 37 | slot of otp.slots().slice(3, 6); 38 | track $index + 3; 39 | let last = $last; 40 | let first = $first 41 | ) { 42 | 50 | } 51 |
52 |
53 |
54 | -------------------------------------------------------------------------------- /src/app/components/site-header/site-header.component.html: -------------------------------------------------------------------------------- 1 |
4 | 51 |
52 | -------------------------------------------------------------------------------- /src/app/components/showcase/showcase.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AfterViewInit, 3 | Component, 4 | Input, 5 | OnDestroy, 6 | signal, 7 | viewChild, 8 | } from '@angular/core'; 9 | import { FormsModule } from '@angular/forms'; 10 | import { InputOTPComponent, REGEXP_ONLY_DIGITS } from '@ngxpert/input-otp'; 11 | import { FakeDashComponent } from '../fake-dash/fake-dash.component'; 12 | import { SlotComponent } from '../slot/slot.component'; 13 | import { cn } from '../../lib/utils'; 14 | @Component({ 15 | selector: 'app-showcase', 16 | templateUrl: './showcase.component.html', 17 | imports: [InputOTPComponent, FormsModule, FakeDashComponent, SlotComponent], 18 | }) 19 | export class ShowcaseComponent implements AfterViewInit, OnDestroy { 20 | otpValue = '12'; 21 | REGEXP_ONLY_DIGITS = REGEXP_ONLY_DIGITS; 22 | cn = cn; 23 | @Input() className = ''; 24 | 25 | otp = viewChild(InputOTPComponent); 26 | disabled = signal(false); 27 | t1: ReturnType | undefined; 28 | t2: ReturnType | undefined; 29 | 30 | onComplete(value: string) { 31 | console.log('OTP completed:', value); 32 | } 33 | ngAfterViewInit() { 34 | const isMobile = window.matchMedia('(max-width: 1023px)').matches; 35 | if (!isMobile) { 36 | this.disabled.set(true); 37 | } 38 | this.t1 = setTimeout(() => { 39 | this.disabled.set(false); 40 | }, 1_900); 41 | 42 | this.t2 = setTimeout( 43 | () => { 44 | this.otp()?.inputRef()?.nativeElement.focus(); 45 | }, 46 | isMobile ? 0 : 2_500, 47 | ); 48 | } 49 | 50 | onSubmit() { 51 | console.log('OTP submitted:', this.otpValue); 52 | } 53 | 54 | ngOnDestroy() { 55 | clearTimeout(this.t1); 56 | clearTimeout(this.t2); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/app/components/ui/button/button.component.ts: -------------------------------------------------------------------------------- 1 | import { Directive, HostBinding, Input } from '@angular/core'; 2 | import { cva, type VariantProps } from 'class-variance-authority'; 3 | 4 | export const buttonVariants = cva( 5 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', 6 | { 7 | variants: { 8 | variant: { 9 | default: 'bg-primary text-primary-foreground hover:bg-primary/90', 10 | destructive: 11 | 'bg-destructive text-destructive-foreground hover:bg-destructive/90', 12 | outline: 13 | 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', 14 | secondary: 15 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 16 | ghost: 'hover:bg-accent hover:text-accent-foreground', 17 | link: 'text-primary underline-offset-4 hover:underline', 18 | }, 19 | size: { 20 | default: 'h-10 px-4 py-2', 21 | sm: 'h-9 rounded-md px-3', 22 | lg: 'h-11 rounded-md px-8', 23 | icon: 'h-10 w-10', 24 | }, 25 | }, 26 | defaultVariants: { 27 | variant: 'default', 28 | size: 'default', 29 | }, 30 | }, 31 | ); 32 | type ButtonProps = VariantProps; 33 | 34 | @Directive({ 35 | selector: 'button[appUiButton], a[appUiButton]', 36 | }) 37 | export class ButtonDirective { 38 | @Input() variant: ButtonProps['variant'] = 'default'; 39 | @Input() size: ButtonProps['size'] = 'default'; 40 | 41 | @HostBinding('class') get class() { 42 | return buttonVariants({ variant: this.variant, size: this.size }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/app/pages/examples/main/main.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { FormsModule } from '@angular/forms'; 3 | import { InputOTPComponent } from '@ngxpert/input-otp'; 4 | import { SlotComponent } from './slot.component'; 5 | import { FakeDashComponent } from './fake-components'; 6 | 7 | @Component({ 8 | selector: 'app-examples-main', 9 | template: ` 10 | 16 |
17 | @for ( 18 | slot of otp.slots().slice(0, 3); 19 | track $index; 20 | let first = $first; 21 | let last = $last 22 | ) { 23 | 31 | } 32 |
33 | 34 |
35 | @for ( 36 | slot of otp.slots().slice(3, 6); 37 | track $index + 3; 38 | let last = $last; 39 | let first = $first 40 | ) { 41 | 49 | } 50 |
51 |
52 | `, 53 | imports: [FormsModule, InputOTPComponent, SlotComponent, FakeDashComponent], 54 | }) 55 | export class ExamplesMainComponent { 56 | otpValue = ''; 57 | } 58 | -------------------------------------------------------------------------------- /projects/ngxpert/input-otp/schematics/ng-add/package-config.ts: -------------------------------------------------------------------------------- 1 | import { Tree } from '@angular-devkit/schematics'; 2 | 3 | interface PackageJson { 4 | dependencies: Record; 5 | } 6 | 7 | /** 8 | * Sorts the keys of the given object. 9 | * @returns A new object instance with sorted keys 10 | */ 11 | function sortObjectByKeys(obj: Record) { 12 | return Object.keys(obj) 13 | .sort() 14 | .reduce( 15 | (result, key) => { 16 | result[key] = obj[key]; 17 | return result; 18 | }, 19 | {} as Record, 20 | ); 21 | } 22 | 23 | /** Adds a package to the package.json in the given host tree. */ 24 | export function addPackageToPackageJson( 25 | host: Tree, 26 | pkg: string, 27 | version: string, 28 | ): Tree { 29 | if (host.exists('package.json')) { 30 | const sourceText = host.read('package.json')!.toString('utf-8'); 31 | const json = JSON.parse(sourceText) as PackageJson; 32 | 33 | if (!json.dependencies) { 34 | json.dependencies = {}; 35 | } 36 | 37 | if (!json.dependencies[pkg]) { 38 | json.dependencies[pkg] = version; 39 | json.dependencies = sortObjectByKeys(json.dependencies); 40 | } 41 | 42 | host.overwrite('package.json', JSON.stringify(json, null, 2)); 43 | } 44 | 45 | return host; 46 | } 47 | 48 | /** Gets the version of the specified package by looking at the package.json in the given tree. */ 49 | export function getPackageVersionFromPackageJson( 50 | tree: Tree, 51 | name: string, 52 | ): string | null { 53 | if (!tree.exists('package.json')) { 54 | return null; 55 | } 56 | 57 | const packageJson = JSON.parse( 58 | tree.read('package.json')!.toString('utf8'), 59 | ) as PackageJson; 60 | 61 | if (packageJson.dependencies && packageJson.dependencies[name]) { 62 | return packageJson.dependencies[name]; 63 | } 64 | 65 | return null; 66 | } 67 | -------------------------------------------------------------------------------- /src/app/components/page-header/page-header.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { cn } from '../../lib/utils'; 3 | import { ComponentWClassDirective } from '../../shared/components/component-w-class/component-w-class.directive'; 4 | @Component({ 5 | selector: 'app-page-header', 6 | template: ` 7 |
15 | 16 |
17 | `, 18 | }) 19 | export class PageHeaderComponent extends ComponentWClassDirective { 20 | cn = cn; 21 | } 22 | 23 | @Component({ 24 | selector: 'app-page-header-heading', 25 | template: ` 26 |

34 | 35 |

36 | `, 37 | }) 38 | export class PageHeaderHeadingComponent extends ComponentWClassDirective { 39 | cn = cn; 40 | } 41 | 42 | @Component({ 43 | selector: 'app-page-header-description', 44 | template: ` 45 |

53 | 54 |

55 | `, 56 | }) 57 | export class PageHeaderDescriptionComponent extends ComponentWClassDirective { 58 | cn = cn; 59 | } 60 | 61 | @Component({ 62 | selector: 'app-page-actions', 63 | template: ` 64 |
72 | 73 |
74 | `, 75 | }) 76 | export class PageActionsComponent extends ComponentWClassDirective { 77 | cn = cn; 78 | } 79 | -------------------------------------------------------------------------------- /src/app/components/code/code.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | // @ts-expect-error TypeScript cannot provide types based on attributes yet 3 | import mainComponentContent from '../../pages/examples/main/main.component' with { loader: 'text' }; 4 | // @ts-expect-error TypeScript cannot provide types based on attributes yet 5 | import slotComponentContent from '../../pages/examples/main/slot.component' with { loader: 'text' }; 6 | // @ts-expect-error TypeScript cannot provide types based on attributes yet 7 | import fakeComponentsContent from '../../pages/examples/main/fake-components' with { loader: 'text' }; 8 | // @ts-expect-error TypeScript cannot provide types based on attributes yet 9 | import utilsContent from '../../pages/examples/main/utils' with { loader: 'text' }; 10 | import { MatTabsModule } from '@angular/material/tabs'; 11 | import { AsyncPipe } from '@angular/common'; 12 | import { CodeHighlightPipe } from '../../shared/pipes/code-highlight.pipe'; 13 | import { SafeHtmlPipe } from '../../shared/pipes/safe-html.pipe'; 14 | import { CopyButtonComponent } from '../copy-button/copy-button.component'; 15 | @Component({ 16 | selector: 'app-code', 17 | templateUrl: './code.component.html', 18 | imports: [ 19 | MatTabsModule, 20 | AsyncPipe, 21 | CodeHighlightPipe, 22 | SafeHtmlPipe, 23 | CopyButtonComponent, 24 | ], 25 | }) 26 | export class CodeComponent { 27 | readonly files: { fileName: string; content: string; language: string }[] = [ 28 | { 29 | fileName: 'main.component.ts', 30 | content: ` 31 | ${mainComponentContent}`, 32 | language: 'angular-ts', 33 | }, 34 | { 35 | fileName: 'slot.component.ts', 36 | content: ` 37 | ${slotComponentContent}`, 38 | language: 'angular-ts', 39 | }, 40 | { 41 | fileName: 'fake-components.ts', 42 | content: ` 43 | ${fakeComponentsContent}`, 44 | language: 'angular-ts', 45 | }, 46 | { 47 | fileName: 'utils.ts', 48 | content: ` 49 | ${utilsContent}`, 50 | language: 'ts', 51 | }, 52 | { 53 | fileName: 'styles.css', 54 | content: ` 55 | @import "tailwindcss"; 56 | 57 | @theme { 58 | --animate-caret-blink: caret-blink 1.2s ease-out infinite; 59 | @keyframes caret-blink { 60 | 0%, 61 | 70%, 62 | 100% { 63 | opacity: 1; 64 | } 65 | 20%, 66 | 50% { 67 | opacity: 0; 68 | } 69 | } 70 | }`, 71 | language: 'css', 72 | }, 73 | ]; 74 | } 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to @ngxpert/input-otp 2 | 3 | 🙏 We would ❤️ for you to contribute to @ngxpert/input-otp and help make it even better than it is today! 4 | 5 | ## Developing 6 | 7 | Start by installing all dependencies: 8 | 9 | ```bash 10 | npm i 11 | ``` 12 | 13 | Run the playground app: 14 | 15 | ```bash 16 | npm run watch:lib 17 | 18 | # in another terminal 19 | npm start 20 | ``` 21 | 22 | ## Testing 23 | 24 | ### Run cypress tests 25 | 26 | ```bash 27 | npm start 28 | 29 | # in another terminal 30 | npx cypress open 31 | ``` 32 | 33 | Cypress window will open, you can click on individual tests. 34 | 35 | ## Coding Rules 36 | 37 | To ensure consistency throughout the source code, keep these rules in mind as you are working: 38 | 39 | - All features or bug fixes **must be tested** by one or more specs (unit-tests). 40 | - All public API methods **must be documented**. 41 | 42 | ## Commit Message Guidelines 43 | 44 | ### TL;DR 45 | 46 | Simply run commit script after staging your files to take care about commit message guidelines 47 | 48 | ```bash 49 | npm run commit 50 | ``` 51 | 52 | ### Details 53 | 54 | We have very precise rules over how our git commit messages can be formatted. This leads to **more 55 | readable messages** that are easy to follow when looking through the **project history**. But also, 56 | we use the git commit messages to **generate the changelog**. 57 | 58 | #### Commit Message Format 59 | 60 | Each commit message consists of a **header**, a **body** and a **footer**. The header has a special 61 | format that includes a **type**, a **scope** and a **subject**: 62 | 63 | ``` 64 | (): 65 | 66 | 67 | 68 |