Skip to content

Commit a22054a

Browse files
committed
feat: add minLengthTrimmed validator and directive with docs
1 parent def86cb commit a22054a

File tree

10 files changed

+388
-0
lines changed

10 files changed

+388
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
.demo-container {
2+
display: flex;
3+
flex-direction: column;
4+
gap: 1rem;
5+
padding: 1rem;
6+
border: 1px solid var(--ng-doc-border-color);
7+
border-radius: 4px;
8+
margin-bottom: 2rem;
9+
}
10+
h3 {
11+
margin-top: 0;
12+
}
13+
input {
14+
padding: 0.5rem;
15+
border: 1px solid var(--ng-doc-border-color);
16+
border-radius: 4px;
17+
width: 100%;
18+
background: var(--ng-doc-input-bg);
19+
color: var(--ng-doc-text-color);
20+
}
21+
.error {
22+
color: #f44336;
23+
font-size: 0.9rem;
24+
}
25+
.success {
26+
color: #4caf50;
27+
font-size: 0.9rem;
28+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Component } from '@angular/core';
2+
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
3+
4+
import {
5+
minLengthTrimmed,
6+
MinLengthTrimmedValidator,
7+
} from 'ngx-oneforall/validators/min-length-trimmed';
8+
9+
@Component({
10+
selector: 'lib-min-length-trimmed-demo',
11+
standalone: true,
12+
imports: [ReactiveFormsModule, FormsModule, MinLengthTrimmedValidator],
13+
template: `
14+
<div class="demo-container">
15+
<h3>Reactive Form</h3>
16+
<label>
17+
Enter at least 3 characters (spaces don't count):
18+
<input type="text" [formControl]="control" placeholder="Type here..." />
19+
</label>
20+
21+
@if (control.errors?.['minLengthTrimmed']; as error) {
22+
<div class="error">
23+
Minimum {{ error.requiredLength }} characters required. Current:
24+
{{ error.actualLength }}
25+
</div>
26+
}
27+
28+
@if (control.valid && control.value) {
29+
<div class="success">
30+
Valid! ({{ control.value.trim().length }} characters)
31+
</div>
32+
}
33+
</div>
34+
35+
<div class="demo-container">
36+
<h3>Template-Driven Form</h3>
37+
<label>
38+
Username (min 3 characters):
39+
<input
40+
type="text"
41+
[(ngModel)]="templateValue"
42+
[minLengthTrimmed]="3"
43+
#templateCtrl="ngModel"
44+
placeholder="Enter username..." />
45+
</label>
46+
47+
@if (templateCtrl.errors?.['minLengthTrimmed']; as error) {
48+
<div class="error">
49+
Minimum {{ error.requiredLength }} characters required. Current:
50+
{{ error.actualLength }}
51+
</div>
52+
}
53+
54+
@if (templateCtrl.valid && templateCtrl.value) {
55+
<div class="success">Valid username!</div>
56+
}
57+
</div>
58+
`,
59+
styleUrl: './min-length-trimmed-demo.component.scss',
60+
})
61+
export class MinLengthTrimmedDemoComponent {
62+
control = new FormControl('', [minLengthTrimmed(3)]);
63+
templateValue: string | null = null;
64+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
![Bundle Size](https://deno.bundlejs.com/badge?q=ngx-oneforall/validators/min-length-trimmed&treeshake=[*]&config={"esbuild":{"external":["rxjs","@angular/core","@angular/common","@angular/forms","@angular/router"]}})
2+
3+
`minLengthTrimmed` is a validator that trims whitespace before checking minimum length.
4+
5+
## Usage
6+
7+
Unlike Angular's built-in `Validators.minLength`, this validator trims the value first, preventing whitespace-only strings from passing validation.
8+
9+
{{ NgDocActions.demo("MinLengthTrimmedDemoComponent", { container: true }) }}
10+
11+
### Reactive Forms
12+
13+
```typescript
14+
import { FormControl } from '@angular/forms';
15+
import { minLengthTrimmed } from 'ngx-oneforall/validators/min-length-trimmed';
16+
17+
const control = new FormControl('', minLengthTrimmed(3));
18+
19+
control.setValue(' ab '); // invalid - trimmed length is 2
20+
control.setValue('abc'); // valid - length is 3
21+
```
22+
23+
### Template-Driven Forms (Directive)
24+
25+
```html
26+
<input type="text" [(ngModel)]="username" [minLengthTrimmed]="3">
27+
```
28+
29+
## API
30+
31+
`minLengthTrimmed(minLength: number): ValidatorFn`
32+
33+
### Error Object
34+
35+
When validation fails, returns:
36+
37+
```typescript
38+
{
39+
minLengthTrimmed: {
40+
requiredLength: number,
41+
actualLength: number
42+
}
43+
}
44+
```
45+
46+
### Behavior
47+
48+
| Value | Min Length | Result |
49+
|-------|------------|--------|
50+
| `null` / `undefined` | any | Valid |
51+
| `'ab'` | 3 | Invalid (length: 2) |
52+
| `' ab '` | 3 | Invalid (trimmed length: 2) |
53+
| `'abc'` | 3 | Valid |
54+
| `' abc '` | 3 | Valid (trimmed length: 3) |
55+
| `' '` | 1 | Invalid (trimmed length: 0) |
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { NgDocPage } from '@ng-doc/core';
2+
import ValidatorsCategory from '../../ng-doc.category';
3+
import { MinLengthTrimmedDemoComponent } from './demo/min-length-trimmed-demo.component';
4+
5+
const MinLengthTrimmedPage: NgDocPage = {
6+
title: 'Min Length Trimmed',
7+
mdFile: './index.md',
8+
category: ValidatorsCategory,
9+
demos: { MinLengthTrimmedDemoComponent },
10+
};
11+
12+
export default MinLengthTrimmedPage;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{ "lib": { "entryFile": "src/public_api.ts" } }
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { Component } from '@angular/core';
2+
import { ComponentFixture, TestBed } from '@angular/core/testing';
3+
import { FormsModule, NgForm } from '@angular/forms';
4+
import { By } from '@angular/platform-browser';
5+
import { MinLengthTrimmedValidator } from './min-length-trimmed.directive';
6+
7+
@Component({
8+
template: `
9+
<form>
10+
<input
11+
type="text"
12+
name="test"
13+
[(ngModel)]="value"
14+
[minLengthTrimmed]="3" />
15+
</form>
16+
`,
17+
imports: [FormsModule, MinLengthTrimmedValidator],
18+
})
19+
class TestHostComponent {
20+
value: string | null = null;
21+
}
22+
23+
describe('MinLengthTrimmedValidator Directive', () => {
24+
let fixture: ComponentFixture<TestHostComponent>;
25+
let component: TestHostComponent;
26+
27+
beforeEach(async () => {
28+
await TestBed.configureTestingModule({
29+
imports: [TestHostComponent],
30+
}).compileComponents();
31+
32+
fixture = TestBed.createComponent(TestHostComponent);
33+
component = fixture.componentInstance;
34+
fixture.detectChanges();
35+
await fixture.whenStable();
36+
});
37+
38+
it('should be valid for null value', async () => {
39+
component.value = null;
40+
fixture.detectChanges();
41+
await fixture.whenStable();
42+
43+
const form = fixture.debugElement.query(By.directive(NgForm));
44+
expect(form.injector.get(NgForm).valid).toBe(true);
45+
});
46+
47+
it('should be valid when trimmed length meets minimum', async () => {
48+
component.value = ' abc ';
49+
fixture.detectChanges();
50+
await fixture.whenStable();
51+
52+
const form = fixture.debugElement.query(By.directive(NgForm));
53+
expect(form.injector.get(NgForm).valid).toBe(true);
54+
});
55+
56+
it('should be invalid when trimmed length is below minimum', async () => {
57+
component.value = ' ab ';
58+
fixture.detectChanges();
59+
await fixture.whenStable();
60+
61+
const form = fixture.debugElement.query(By.directive(NgForm));
62+
expect(form.injector.get(NgForm).valid).toBe(false);
63+
});
64+
65+
it('should be invalid for whitespace-only string', async () => {
66+
component.value = ' ';
67+
fixture.detectChanges();
68+
await fixture.whenStable();
69+
70+
const form = fixture.debugElement.query(By.directive(NgForm));
71+
expect(form.injector.get(NgForm).valid).toBe(false);
72+
});
73+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {
2+
Directive,
3+
effect,
4+
forwardRef,
5+
input,
6+
numberAttribute,
7+
} from '@angular/core';
8+
import { NG_VALIDATORS } from '@angular/forms';
9+
import { BaseValidator } from 'ngx-oneforall/validators/base';
10+
import { minLengthTrimmed } from './min-length-trimmed.validator';
11+
12+
/**
13+
* Directive that validates the trimmed minimum length of a form control's value.
14+
*
15+
* @example
16+
* ```html
17+
* <input type="text" [(ngModel)]="username" [minLengthTrimmed]="3">
18+
* ```
19+
*/
20+
@Directive({
21+
selector:
22+
'[minLengthTrimmed][formControlName],[minLengthTrimmed][formControl],[minLengthTrimmed][ngModel]',
23+
providers: [
24+
{
25+
provide: NG_VALIDATORS,
26+
useExisting: forwardRef(() => MinLengthTrimmedValidator),
27+
multi: true,
28+
},
29+
],
30+
})
31+
export class MinLengthTrimmedValidator extends BaseValidator {
32+
/**
33+
* The minimum length the trimmed value must have.
34+
*/
35+
minLengthTrimmedValue = input.required<number, number | string>({
36+
alias: 'minLengthTrimmed',
37+
transform: numberAttribute,
38+
});
39+
40+
constructor() {
41+
super();
42+
effect(() => {
43+
const length = this.minLengthTrimmedValue();
44+
this.validator = minLengthTrimmed(length);
45+
this.onChange?.();
46+
});
47+
}
48+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { FormControl } from '@angular/forms';
2+
import { minLengthTrimmed } from './min-length-trimmed.validator';
3+
4+
describe('minLengthTrimmed', () => {
5+
it('should return null for null value', () => {
6+
const validator = minLengthTrimmed(3);
7+
expect(validator(new FormControl(null))).toBeNull();
8+
});
9+
10+
it('should return null for undefined value', () => {
11+
const validator = minLengthTrimmed(3);
12+
expect(validator(new FormControl(undefined))).toBeNull();
13+
});
14+
15+
it('should return null for non-string values', () => {
16+
const validator = minLengthTrimmed(3);
17+
expect(validator(new FormControl(123))).toBeNull();
18+
expect(validator(new FormControl({}))).toBeNull();
19+
});
20+
21+
it('should return null when trimmed length meets minimum', () => {
22+
const validator = minLengthTrimmed(3);
23+
expect(validator(new FormControl('abc'))).toBeNull();
24+
expect(validator(new FormControl('abcd'))).toBeNull();
25+
expect(validator(new FormControl(' abc '))).toBeNull();
26+
});
27+
28+
it('should return error when trimmed length is below minimum', () => {
29+
const validator = minLengthTrimmed(3);
30+
expect(validator(new FormControl('ab'))).toEqual({
31+
minLengthTrimmed: { requiredLength: 3, actualLength: 2 },
32+
});
33+
expect(validator(new FormControl(' ab '))).toEqual({
34+
minLengthTrimmed: { requiredLength: 3, actualLength: 2 },
35+
});
36+
});
37+
38+
it('should return error for empty string', () => {
39+
const validator = minLengthTrimmed(1);
40+
expect(validator(new FormControl(''))).toEqual({
41+
minLengthTrimmed: { requiredLength: 1, actualLength: 0 },
42+
});
43+
});
44+
45+
it('should return error for whitespace-only string', () => {
46+
const validator = minLengthTrimmed(1);
47+
expect(validator(new FormControl(' '))).toEqual({
48+
minLengthTrimmed: { requiredLength: 1, actualLength: 0 },
49+
});
50+
});
51+
52+
it('should work with minLength of 0', () => {
53+
const validator = minLengthTrimmed(0);
54+
expect(validator(new FormControl(''))).toBeNull();
55+
expect(validator(new FormControl(' '))).toBeNull();
56+
});
57+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
2+
import { isPresent } from 'ngx-oneforall/utils/is-present';
3+
4+
/**
5+
* Validator that checks if the trimmed control value meets a minimum length requirement.
6+
* Unlike Angular's built-in `Validators.minLength`, this validator trims whitespace
7+
* before calculating the length.
8+
*
9+
* @param minLength - The minimum length the trimmed value must have.
10+
* @returns A validator function that returns an error object if invalid, or `null` if valid.
11+
*
12+
* @example
13+
* ```typescript
14+
* import { FormControl } from '@angular/forms';
15+
* import { minLengthTrimmed } from 'ngx-oneforall/validators/min-length-trimmed';
16+
*
17+
* const control = new FormControl('', minLengthTrimmed(3));
18+
* control.setValue('ab'); // invalid - only 2 characters
19+
* control.setValue(' ab '); // invalid - trimmed length is 2
20+
* control.setValue('abc'); // valid - 3 characters
21+
* ```
22+
*
23+
* @remarks
24+
* - Returns `null` if the value is `null`, `undefined`, or not a string.
25+
* - The error object includes `requiredLength` and `actualLength` for display purposes.
26+
*/
27+
export function minLengthTrimmed(minLength: number): ValidatorFn {
28+
return (control: AbstractControl): ValidationErrors | null => {
29+
const value = control.value;
30+
31+
if (!isPresent(value) || typeof value !== 'string') {
32+
return null;
33+
}
34+
35+
const trimmedLength = value.trim().length;
36+
37+
if (trimmedLength < minLength) {
38+
return {
39+
minLengthTrimmed: {
40+
requiredLength: minLength,
41+
actualLength: trimmedLength,
42+
},
43+
};
44+
}
45+
46+
return null;
47+
};
48+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './min-length-trimmed.validator';
2+
export * from './min-length-trimmed.directive';

0 commit comments

Comments
 (0)