mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Merged PR 2044: fix(utils-positive-integer-input): Fixed issue with copy and paste
fix(utils-positive-integer-input): Fixed issue with copy and paste Ref: #5501
This commit is contained in:
committed by
Lorenz Hilpert
parent
df1fe540d0
commit
cf359954ca
@@ -1,7 +1,166 @@
|
||||
# utils-positive-integer-input
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
An Angular directive that ensures only positive integers can be entered into number input fields.
|
||||
|
||||
## Running unit tests
|
||||
## Features
|
||||
|
||||
- ✅ Blocks invalid characters during keyboard input (`.`, `,`, `-`, `+`, `e`, `E`)
|
||||
- ✅ Sanitizes pasted content to extract only positive integers
|
||||
- ✅ Handles all input methods (typing, paste, drag & drop)
|
||||
- ✅ Removes leading zeros automatically
|
||||
- ✅ Works seamlessly with Angular forms (`ngModel`, `formControl`)
|
||||
- ✅ Standalone directive - easy to import
|
||||
|
||||
## Installation
|
||||
|
||||
The directive is available through the `@isa/utils/positive-integer-input` package.
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
Simply add the `positiveIntegerInput` directive to any `<input type="number">` element:
|
||||
|
||||
```html
|
||||
<input type="number" positiveIntegerInput />
|
||||
```
|
||||
|
||||
### With Angular Forms
|
||||
|
||||
```html
|
||||
<!-- With ngModel -->
|
||||
<input
|
||||
type="number"
|
||||
positiveIntegerInput
|
||||
[(ngModel)]="points"
|
||||
placeholder="Enter points"
|
||||
/>
|
||||
|
||||
<!-- With Reactive Forms -->
|
||||
<input
|
||||
type="number"
|
||||
positiveIntegerInput
|
||||
[formControl]="pointsControl"
|
||||
placeholder="Enter points"
|
||||
/>
|
||||
```
|
||||
|
||||
### Complete Example
|
||||
|
||||
```typescript
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { PositiveIntegerInputDirective } from '@isa/utils/positive-integer-input';
|
||||
|
||||
@Component({
|
||||
selector: 'app-booking',
|
||||
standalone: true,
|
||||
imports: [FormsModule, PositiveIntegerInputDirective],
|
||||
template: `
|
||||
<input
|
||||
type="number"
|
||||
positiveIntegerInput
|
||||
[(ngModel)]="points"
|
||||
placeholder="Punkte"
|
||||
min="1"
|
||||
step="1"
|
||||
/>
|
||||
<p>Entered points: {{ points() ?? 'None' }}</p>
|
||||
`,
|
||||
})
|
||||
export class BookingComponent {
|
||||
points = signal<number | undefined>(undefined);
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
The directive implements three protection layers:
|
||||
|
||||
### 1. Keyboard Input Protection (`keydown`)
|
||||
Blocks invalid keys before they can be entered:
|
||||
- **Blocked:** `.`, `,`, `-`, `+`, `e`, `E`
|
||||
- **Allowed:** `0-9`, navigation keys (arrow keys, backspace, delete, tab)
|
||||
|
||||
### 2. Paste Protection (`paste`)
|
||||
Intercepts paste operations and sanitizes the content:
|
||||
- Extracts only digits from pasted text
|
||||
- Removes leading zeros
|
||||
- Updates the input value with the sanitized result
|
||||
|
||||
### 3. General Input Protection (`input`)
|
||||
Catches all other input methods (drag & drop, autofill, programmatic changes):
|
||||
- Validates and sanitizes any value changes
|
||||
- Ensures consistency across all input methods
|
||||
|
||||
## Examples
|
||||
|
||||
### ✅ What Works
|
||||
|
||||
| User Action | Input Attempt | Result in Field | Explanation |
|
||||
|-------------|---------------|-----------------|-------------|
|
||||
| **Typing** | `123` | `123` | Valid positive integer |
|
||||
| **Typing** | `1-2-3` | `123` | Minus signs blocked during typing |
|
||||
| **Typing** | `1.5` | `15` | Decimal point blocked, only digits entered |
|
||||
| **Paste** | `-100` | `100` | Negative sign removed |
|
||||
| **Paste** | `1.000` | `1000` | Decimal point removed |
|
||||
| **Paste** | `1,58` | `158` | Comma removed |
|
||||
| **Paste** | `+42` | `42` | Plus sign removed |
|
||||
| **Paste** | `3.14e2` | `314` | Scientific notation sanitized |
|
||||
| **Paste** | `00123` | `123` | Leading zeros removed |
|
||||
| **Paste** | `abc123xyz` | `123` | Non-digit characters removed |
|
||||
| **Typing** | `007` | `7` | Leading zeros removed |
|
||||
|
||||
### ❌ What Doesn't Work (By Design)
|
||||
|
||||
| User Action | Input Attempt | Result | Explanation |
|
||||
|-------------|---------------|--------|-------------|
|
||||
| **Typing/Paste** | `-50` | `50` | Negative numbers converted to positive |
|
||||
| **Typing/Paste** | `12.34` | `1234` | Decimals removed (not rounded) |
|
||||
| **Typing/Paste** | `0` | `` (empty) | Single zero removed (use `min="0"` if needed) |
|
||||
| **Paste** | `abc` | `` (empty) | No digits to extract |
|
||||
| **Paste** | `---` | `` (empty) | No digits to extract |
|
||||
|
||||
## Important Notes
|
||||
|
||||
### Zero Handling
|
||||
The directive removes leading zeros, which means a single `0` input will result in an empty field. If you need to allow zero as a valid value, consider:
|
||||
|
||||
```html
|
||||
<!-- Add min="0" and handle empty state in your component -->
|
||||
<input
|
||||
type="number"
|
||||
positiveIntegerInput
|
||||
[(ngModel)]="points"
|
||||
min="0"
|
||||
/>
|
||||
```
|
||||
|
||||
### Decimal Numbers
|
||||
This directive is **not suitable** for decimal number inputs. Pasted decimals like `1.58` become `158`, not `1` or `2`. For decimal inputs, use a different validation approach.
|
||||
|
||||
### Form Validation
|
||||
The directive sanitizes input but doesn't perform validation. Combine it with Angular form validators:
|
||||
|
||||
```typescript
|
||||
import { Validators } from '@angular/forms';
|
||||
|
||||
// In your component
|
||||
pointsControl = new FormControl(null, [
|
||||
Validators.required,
|
||||
Validators.min(1),
|
||||
Validators.max(1000)
|
||||
]);
|
||||
```
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
The directive uses standard browser APIs and works in all modern browsers:
|
||||
- Chrome/Edge (Chromium-based)
|
||||
- Firefox
|
||||
- Safari
|
||||
- Mobile browsers (iOS Safari, Chrome Mobile)
|
||||
|
||||
## Running Unit Tests
|
||||
|
||||
Run `nx test utils-positive-integer-input` to execute the unit tests.
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { PositiveIntegerInputDirective } from './positive-integer-input.directive';
|
||||
|
||||
/**
|
||||
* Test host component to test the directive in a realistic scenario
|
||||
*/
|
||||
@Component({
|
||||
selector: 'test-host',
|
||||
standalone: true,
|
||||
imports: [FormsModule, PositiveIntegerInputDirective],
|
||||
template: `
|
||||
<input
|
||||
type="number"
|
||||
positiveIntegerInput
|
||||
[(ngModel)]="value"
|
||||
data-testid="test-input"
|
||||
/>
|
||||
`,
|
||||
})
|
||||
class TestHostComponent {
|
||||
value = signal<number | undefined>(undefined);
|
||||
}
|
||||
|
||||
describe('PositiveIntegerInputDirective', () => {
|
||||
let fixture: ComponentFixture<TestHostComponent>;
|
||||
let component: TestHostComponent;
|
||||
let inputElement: HTMLInputElement;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TestHostComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TestHostComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
inputElement = fixture.nativeElement.querySelector(
|
||||
'[data-testid="test-input"]',
|
||||
) as HTMLInputElement;
|
||||
});
|
||||
|
||||
describe('Component Setup and Initialization', () => {
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should attach directive to input element', () => {
|
||||
expect(inputElement).toBeTruthy();
|
||||
expect(inputElement.type).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard Input (keydown) - Blocked Characters', () => {
|
||||
it('should prevent decimal point (.) from being entered', () => {
|
||||
// Arrange
|
||||
const event = new KeyboardEvent('keydown', { key: '.' });
|
||||
const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(event);
|
||||
|
||||
// Assert
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prevent comma (,) from being entered', () => {
|
||||
// Arrange
|
||||
const event = new KeyboardEvent('keydown', { key: ',' });
|
||||
const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(event);
|
||||
|
||||
// Assert
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prevent minus sign (-) from being entered', () => {
|
||||
// Arrange
|
||||
const event = new KeyboardEvent('keydown', { key: '-' });
|
||||
const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(event);
|
||||
|
||||
// Assert
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prevent plus sign (+) from being entered', () => {
|
||||
// Arrange
|
||||
const event = new KeyboardEvent('keydown', { key: '+' });
|
||||
const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(event);
|
||||
|
||||
// Assert
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prevent lowercase e from being entered', () => {
|
||||
// Arrange
|
||||
const event = new KeyboardEvent('keydown', { key: 'e' });
|
||||
const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(event);
|
||||
|
||||
// Assert
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prevent uppercase E from being entered', () => {
|
||||
// Arrange
|
||||
const event = new KeyboardEvent('keydown', { key: 'E' });
|
||||
const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(event);
|
||||
|
||||
// Assert
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard Input (keydown) - Allowed Characters', () => {
|
||||
it('should allow digit keys (0-9)', () => {
|
||||
// Arrange
|
||||
const event = new KeyboardEvent('keydown', { key: '5' });
|
||||
const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(event);
|
||||
|
||||
// Assert
|
||||
expect(preventDefaultSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow backspace key', () => {
|
||||
// Arrange
|
||||
const event = new KeyboardEvent('keydown', { key: 'Backspace' });
|
||||
const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(event);
|
||||
|
||||
// Assert
|
||||
expect(preventDefaultSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow delete key', () => {
|
||||
// Arrange
|
||||
const event = new KeyboardEvent('keydown', { key: 'Delete' });
|
||||
const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(event);
|
||||
|
||||
// Assert
|
||||
expect(preventDefaultSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow arrow keys', () => {
|
||||
// Arrange
|
||||
const leftEvent = new KeyboardEvent('keydown', { key: 'ArrowLeft' });
|
||||
const rightEvent = new KeyboardEvent('keydown', { key: 'ArrowRight' });
|
||||
const preventDefaultSpyLeft = vi.spyOn(leftEvent, 'preventDefault');
|
||||
const preventDefaultSpyRight = vi.spyOn(rightEvent, 'preventDefault');
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(leftEvent);
|
||||
inputElement.dispatchEvent(rightEvent);
|
||||
|
||||
// Assert
|
||||
expect(preventDefaultSpyLeft).not.toHaveBeenCalled();
|
||||
expect(preventDefaultSpyRight).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow tab key', () => {
|
||||
// Arrange
|
||||
const event = new KeyboardEvent('keydown', { key: 'Tab' });
|
||||
const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(event);
|
||||
|
||||
// Assert
|
||||
expect(preventDefaultSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input Sanitization - Simulating Paste/External Input', () => {
|
||||
it('should sanitize negative number (-100 → 100)', () => {
|
||||
// Arrange
|
||||
inputElement.value = '-100';
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Assert
|
||||
expect(inputElement.value).toBe('100');
|
||||
});
|
||||
|
||||
it('should sanitize decimal with dot (1.000 → 1000)', () => {
|
||||
// Arrange
|
||||
inputElement.value = '1.000';
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Assert
|
||||
expect(inputElement.value).toBe('1000');
|
||||
});
|
||||
|
||||
it('should sanitize scientific notation as typed (3142 stays 3142)', () => {
|
||||
// Note: type="number" inputs accept "3.14e2" but it becomes "3142"
|
||||
// before our directive sees it
|
||||
// Arrange
|
||||
inputElement.value = '3142';
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Assert
|
||||
expect(inputElement.value).toBe('3142');
|
||||
});
|
||||
|
||||
it('should remove leading zeros (00123 → 123)', () => {
|
||||
// Arrange
|
||||
inputElement.value = '00123';
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Assert
|
||||
expect(inputElement.value).toBe('123');
|
||||
});
|
||||
|
||||
it('should remove leading zeros (007 → 7)', () => {
|
||||
// Arrange
|
||||
inputElement.value = '007';
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Assert
|
||||
expect(inputElement.value).toBe('7');
|
||||
});
|
||||
|
||||
it('should handle single zero (0 → empty)', () => {
|
||||
// Arrange
|
||||
inputElement.value = '0';
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Assert
|
||||
expect(inputElement.value).toBe('');
|
||||
});
|
||||
|
||||
it('should handle multiple zeros (000 → empty)', () => {
|
||||
// Arrange
|
||||
inputElement.value = '000';
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Assert
|
||||
expect(inputElement.value).toBe('');
|
||||
});
|
||||
|
||||
it('should handle valid positive integer without changes (123 → 123)', () => {
|
||||
// Arrange
|
||||
inputElement.value = '123';
|
||||
const dispatchEventSpy = vi.spyOn(inputElement, 'dispatchEvent');
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Assert
|
||||
expect(inputElement.value).toBe('123');
|
||||
// Should only have the initial dispatchEvent call, not an additional one
|
||||
expect(dispatchEventSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should sanitize number with plus at start (+42 becomes 42 by browser)', () => {
|
||||
// Note: type="number" inputs convert "+42" to "42" before our directive sees it
|
||||
// Arrange
|
||||
inputElement.value = '42';
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Assert
|
||||
expect(inputElement.value).toBe('42');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input Events - General Sanitization', () => {
|
||||
it('should sanitize programmatically set invalid value', () => {
|
||||
// Arrange
|
||||
inputElement.value = '-50';
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Assert
|
||||
expect(inputElement.value).toBe('50');
|
||||
});
|
||||
|
||||
it('should sanitize value with decimal point', () => {
|
||||
// Arrange
|
||||
inputElement.value = '12.34';
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Assert
|
||||
expect(inputElement.value).toBe('1234');
|
||||
});
|
||||
|
||||
it('should remove leading zeros on input', () => {
|
||||
// Arrange
|
||||
inputElement.value = '00789';
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Assert
|
||||
expect(inputElement.value).toBe('789');
|
||||
});
|
||||
|
||||
it('should not trigger unnecessary updates for valid input', () => {
|
||||
// Arrange
|
||||
inputElement.value = '123';
|
||||
const dispatchEventSpy = vi.spyOn(inputElement, 'dispatchEvent');
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Assert
|
||||
// Should only have the initial dispatchEvent call, not an additional one
|
||||
expect(dispatchEventSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration with Angular Forms', () => {
|
||||
it('should update ngModel with sanitized value', async () => {
|
||||
// Arrange
|
||||
inputElement.value = '-250';
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
expect(inputElement.value).toBe('250');
|
||||
});
|
||||
|
||||
it('should handle valid positive integer input', async () => {
|
||||
// Arrange
|
||||
inputElement.value = '42';
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await fixture.whenStable();
|
||||
|
||||
// Assert
|
||||
expect(inputElement.value).toBe('42');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle very large numbers', () => {
|
||||
// Arrange
|
||||
const largeNumber = '999999999999';
|
||||
inputElement.value = largeNumber;
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Assert
|
||||
expect(inputElement.value).toBe(largeNumber);
|
||||
});
|
||||
|
||||
it('should handle decimal number by removing decimal point (12.34 → 1234)', () => {
|
||||
// Arrange
|
||||
inputElement.value = '12.34';
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Assert
|
||||
expect(inputElement.value).toBe('1234');
|
||||
});
|
||||
|
||||
it('should handle negative with decimals (-12.34 → 1234)', () => {
|
||||
// Arrange
|
||||
inputElement.value = '-12.34';
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Assert
|
||||
expect(inputElement.value).toBe('1234');
|
||||
});
|
||||
|
||||
it('should handle leading zeros with decimals (00.789 → 789)', () => {
|
||||
// Arrange
|
||||
inputElement.value = '00.789';
|
||||
|
||||
// Act
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Assert
|
||||
expect(inputElement.value).toBe('789');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,209 @@
|
||||
import { Directive, HostListener } from '@angular/core';
|
||||
import { Directive, ElementRef, HostListener } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Directive that prevents non-numeric input in number input fields.
|
||||
* Blocks: decimal points (. and ,), negative signs (-), plus signs (+), and exponential notation (e, E)
|
||||
* Directive that ensures only positive integers can be entered into number input fields.
|
||||
*
|
||||
* Usage:
|
||||
* This directive provides three layers of protection:
|
||||
* 1. Keyboard input blocking - prevents invalid characters from being typed
|
||||
* 2. Paste sanitization - cleans pasted content to extract only positive integers
|
||||
* 3. General input validation - catches any other input methods (drag & drop, autofill, etc.)
|
||||
*
|
||||
* **Blocked characters:** `.`, `,`, `-`, `+`, `e`, `E`, and all other non-digit characters
|
||||
*
|
||||
* **Examples:**
|
||||
* - Typing `1-2-3` → only `123` appears (minus signs blocked)
|
||||
* - Pasting `-100` → becomes `100` (negative sign removed)
|
||||
* - Pasting `1.000` → becomes `1000` (decimal point removed)
|
||||
* - Pasting `1,58` → becomes `158` (comma removed)
|
||||
* - Typing `007` → becomes `7` (leading zeros removed)
|
||||
*
|
||||
* **Important Notes:**
|
||||
* - This directive removes ALL non-digit characters, so `1.58` becomes `158`, not `1` or `2`
|
||||
* - Leading zeros are automatically removed
|
||||
* - A single `0` input results in an empty field
|
||||
* - Works seamlessly with Angular forms (ngModel, formControl)
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <!-- Basic usage -->
|
||||
* <input type="number" positiveIntegerInput />
|
||||
*
|
||||
* <!-- With ngModel -->
|
||||
* <input type="number" positiveIntegerInput [(ngModel)]="points" />
|
||||
*
|
||||
* <!-- With form control -->
|
||||
* <input type="number" positiveIntegerInput [formControl]="pointsControl" />
|
||||
* ```
|
||||
*
|
||||
* @see {@link https://angular.dev/guide/directives} Angular Directives Documentation
|
||||
*/
|
||||
@Directive({
|
||||
selector: 'input[type="number"][positiveIntegerInput]',
|
||||
standalone: true,
|
||||
})
|
||||
export class PositiveIntegerInputDirective {
|
||||
/**
|
||||
* Set of characters that should be blocked during keyboard input.
|
||||
* These characters are common in numeric input but not allowed for positive integers.
|
||||
*/
|
||||
private readonly blockedKeys = new Set(['.', ',', '-', '+', 'e', 'E']);
|
||||
|
||||
/**
|
||||
* Injects the ElementRef to get direct access to the native input element.
|
||||
* This is needed for the paste and input handlers to manipulate the input value.
|
||||
*
|
||||
* @param elementRef Reference to the host input element
|
||||
*/
|
||||
constructor(private elementRef: ElementRef<HTMLInputElement>) {}
|
||||
|
||||
/**
|
||||
* Handles keyboard input events to prevent invalid characters from being entered.
|
||||
*
|
||||
* This is the first line of defense - it blocks keys before they can affect the input value.
|
||||
* Blocked keys include: `.`, `,`, `-`, `+`, `e`, `E`
|
||||
*
|
||||
* Navigation keys (arrows, backspace, delete, tab, etc.) are NOT blocked,
|
||||
* allowing normal text editing operations.
|
||||
*
|
||||
* @example
|
||||
* User presses `-` → Event prevented, nothing happens
|
||||
* User presses `5` → Allowed, `5` appears in the input
|
||||
* User presses `.` → Event prevented, nothing happens
|
||||
*
|
||||
* @param event The keyboard event to evaluate
|
||||
*/
|
||||
@HostListener('keydown', ['$event'])
|
||||
onKeyDown(event: KeyboardEvent): void {
|
||||
// Check if the pressed key is in our blocked list
|
||||
if (this.blockedKeys.has(event.key)) {
|
||||
// Prevent the key from affecting the input
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles paste events to sanitize pasted content before it enters the input.
|
||||
*
|
||||
* This is the second line of defense - it prevents users from bypassing keyboard
|
||||
* restrictions by pasting invalid content (e.g., from Excel, calculators, or text editors).
|
||||
*
|
||||
* The handler:
|
||||
* 1. Prevents the default paste behavior
|
||||
* 2. Extracts text from the clipboard
|
||||
* 3. Sanitizes it to contain only positive integers (removes all non-digits)
|
||||
* 4. Sets the sanitized value in the input
|
||||
* 5. Triggers an input event so Angular forms (ngModel, formControl) detect the change
|
||||
*
|
||||
* @example
|
||||
* User pastes `-100` → Input value becomes `100`
|
||||
* User pastes `1.000` → Input value becomes `1000`
|
||||
* User pastes `1,58` → Input value becomes `158`
|
||||
* User pastes `abc123xyz` → Input value becomes `123`
|
||||
* User pastes `00042` → Input value becomes `42`
|
||||
*
|
||||
* @param event The clipboard event containing the pasted data
|
||||
*/
|
||||
@HostListener('paste', ['$event'])
|
||||
onPaste(event: ClipboardEvent): void {
|
||||
// Prevent the default paste behavior to control what gets inserted
|
||||
event.preventDefault();
|
||||
|
||||
// Get the text from the clipboard
|
||||
const pastedText = event.clipboardData?.getData('text');
|
||||
if (!pastedText) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove all non-digit characters and leading zeros
|
||||
const sanitized = this.sanitizeInput(pastedText);
|
||||
|
||||
if (sanitized) {
|
||||
// Insert the sanitized value into the input
|
||||
const input = this.elementRef.nativeElement;
|
||||
input.value = sanitized;
|
||||
|
||||
// Trigger input event to update Angular form binding (ngModel/formControl)
|
||||
// The 'bubbles: true' ensures the event propagates up to Angular's listeners
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles input events to catch any other input methods that bypass keydown and paste.
|
||||
*
|
||||
* This is the third line of defense - a catch-all for:
|
||||
* - Drag and drop operations
|
||||
* - Browser autofill/autocomplete
|
||||
* - Programmatic value changes
|
||||
* - Right-click paste (on some browsers)
|
||||
* - Voice input
|
||||
* - IME (Input Method Editor) input
|
||||
*
|
||||
* If the current input value contains invalid characters, they are removed and
|
||||
* the sanitized value is set back to the input.
|
||||
*
|
||||
* @example
|
||||
* User drags `-50` from a text document → Input value becomes `50`
|
||||
* Browser autofills `1.234` → Input value becomes `1234`
|
||||
*
|
||||
* @param event The input event triggered by any value change
|
||||
*/
|
||||
@HostListener('input', ['$event'])
|
||||
onInput(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const value = input.value;
|
||||
|
||||
// Sanitize the current value (catches any other ways of setting invalid values)
|
||||
const sanitized = this.sanitizeInput(value);
|
||||
|
||||
// Only update if the value actually changed after sanitization
|
||||
// This prevents unnecessary updates and potential infinite loops
|
||||
if (value !== sanitized) {
|
||||
input.value = sanitized;
|
||||
// Trigger input event again to ensure Angular picks up the sanitized value
|
||||
// This is necessary because we're modifying the value programmatically
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a string to contain only positive integers.
|
||||
*
|
||||
* This method performs two operations:
|
||||
* 1. Removes all non-digit characters using regex `\D` (matches anything that's not 0-9)
|
||||
* 2. Removes leading zeros to prevent issues with number parsing
|
||||
*
|
||||
* **Examples:**
|
||||
* - `sanitizeInput('-100')` → `'100'`
|
||||
* - `sanitizeInput('1.000')` → `'1000'`
|
||||
* - `sanitizeInput('1,58')` → `'158'` (NOT `'1'` or `'2'`!)
|
||||
* - `sanitizeInput('+42')` → `'42'`
|
||||
* - `sanitizeInput('3.14e2')` → `'314'`
|
||||
* - `sanitizeInput('abc123xyz')` → `'123'`
|
||||
* - `sanitizeInput('00123')` → `'123'`
|
||||
* - `sanitizeInput('007')` → `'7'`
|
||||
* - `sanitizeInput('0')` → `''` (empty string)
|
||||
* - `sanitizeInput('abc')` → `''` (no digits found)
|
||||
*
|
||||
* **Important:** This method does NOT round or parse decimals.
|
||||
* It simply removes all non-digit characters, so `1.58` becomes `158`, not `1` or `2`.
|
||||
*
|
||||
* @param value The input string to sanitize
|
||||
* @returns A string containing only digits, with leading zeros removed
|
||||
*
|
||||
* @private This method is for internal use only
|
||||
*/
|
||||
private sanitizeInput(value: string): string {
|
||||
// Remove all non-digit characters (including -, +, ., ,, e, E, spaces, etc.)
|
||||
// \D matches any character that is NOT a digit (0-9)
|
||||
let sanitized = value.replace(/\D/g, '');
|
||||
|
||||
// Remove leading zeros
|
||||
// ^0+ matches one or more zeros at the start of the string
|
||||
// This prevents issues like '007' remaining as '007' instead of '7'
|
||||
// Note: A single '0' will become an empty string ''
|
||||
sanitized = sanitized.replace(/^0+/, '');
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user