はじめに
絵文字を使ってもバグらずに動くValidatorとDirectiveを作成します。
サロゲートペアについて
サロゲートペアとは、2つの文字コードを使って表現される文字を指し、通常の方法では正しく文字数をカウントできません。サロゲートペアには絵文字や一部の漢字が含まれます。次の例を見てください。
'A'.length;
'😀'.length;
'🏴'.length;
例2の絵文字はサロゲートペアのため、2文字としてカウントされているのが分かります。また、例3のウェールズ国旗のような特殊な絵文字は、複数の絵文字で構成されているため、1文字で14文字としてカウントされています。
環境
TypeScriptベースのフレームワークであるAngularを使用します。
- Angular: 8.2.14
- stringz: 2.1.0
$ ng versionの実行結果
Angular CLI: 8.3.29
Node: 12.13.1
OS: darwin x64
Angular: 8.2.14
... animations, common, compiler, compiler-cli, core, forms
... language-service, platform-browser, platform-browser-dynamic
... router
Package Version
-----------------------------------------------------------
@angular-devkit/architect 0.803.29
@angular-devkit/build-angular 0.803.29
@angular-devkit/build-optimizer 0.803.29
@angular-devkit/build-webpack 0.803.29
@angular-devkit/core 8.3.29
@angular-devkit/schematics 8.3.29
@angular/cli 8.3.29
@ngtools/webpack 8.3.29
@schematics/angular 8.3.29
@schematics/update 0.803.29
rxjs 6.4.0
typescript 3.5.3
webpack 4.39.2
絵文字を正確にカウントできるライブラリ
絵文字をカウントする処理をスクラッチで書くと大変なので下記のライブラリを使用します。
github.com
上記のライブラリを使えば、通常の処理では難しい絵文字を含む文字列のカウントや、文字列操作を使用することができます。
import { substring, length } from 'stringz';
length('Iñtërnâtiônàlizætiøn☃💩');
substring('Emojis 👍🏽 are 🍆 poison. 🌮s are bad.', 7, 14);
Validatorの作成
上記のライブラリstringz
を使用して、サロゲートペア(絵文字など)を含んだ文字列の最小文字数、最大文字数のバリデーターを作成します。
いわゆるAngularの公式バリデーターであるValidators.minLength(), Validators.maxLength()
の絵文字に対応したバージョンになります。
surrogate-pair.validator.ts
import { length } from 'stringz';
import { Injectable } from '@angular/core';
import { AbstractControl, ValidatorFn } from '@angular/forms';
@Injectable()
export class SurrogatePairValidator {
@param{number}
@returns
public static minLength(minLength: number): ValidatorFn {
return (control: AbstractControl): { [key: string]: any } => {
const value = (control.value || '') + '';
if (value === '') {
return null;
}
return length(value) >= minLength
? null
: { minlength: { requiredLength: minLength } };
};
}
@param{number}
@return
public static maxLength(maxLength: number): ValidatorFn {
return (control: AbstractControl): { [key: string]: any } => {
const value = (control.value || '') + '';
if (value === '') {
return null;
}
return length(value) <= maxLength
? null
: { maxlength: { requiredLength: maxLength } };
};
}
}
上記のバリデーターのテストコードをjasmine + Karmaで書きます。
surrogate-pair.validator.spec.ts
import { Component, OnInit } from '@angular/core';
import {
FormBuilder,
FormGroup,
ReactiveFormsModule
} from '@angular/forms';
import { SurrogatePairValidator } from './surrogate-pair.validator';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
@classRegisterComponent
@Component({
selector: 'app-register',
template: `
<form [formGroup]="form">
<label>minLength</label>
<input formControlName="minLength" />
<label>maxLength</label>
<input formControlName="maxLength" />
</form>
`
})
export class RegisterComponent implements OnInit {
public form: FormGroup;
constructor(private fb: FormBuilder) {}
public ngOnInit(): void {
this.form = this.fb.group({
minLength: [null, SurrogatePairValidator.minLength(5)],
maxLength: [null, SurrogatePairValidator.maxLength(5)]
});
}
}
@param{string}
@param{RegisterComponent}
@param{string[]}
@param{string[]}
function assertValues(
field_name: string,
component: RegisterComponent,
valid_inputs: string[],
invalid_inputs: string[]
): void {
const control = component.form.controls[field_name];
for (const invalid of invalid_inputs) {
control.setValue(invalid);
expect(control.valid).toBeFalsy();
}
for (const valid of valid_inputs) {
control.setValue(valid);
expect(control.valid).toBeTruthy();
}
}
describe('SurrogatePairValidator', () => {
let component: RegisterComponent;
let fixture: ComponentFixture<RegisterComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ReactiveFormsModule, FormsModule],
declarations: [RegisterComponent]
});
fixture = TestBed.createComponent(RegisterComponent);
component = fixture.componentInstance;
component.ngOnInit();
});
it('minLength', () => {
const valid_inputs = ['12🐍4🏴', '1吉😀家😀🏴😱', '', null];
const invalid_inputs = ['😀😀😀😀', '😀😀 😀', 'abce', '1234'];
assertValues(
'minLength',
component,
valid_inputs,
invalid_inputs
);
});
it('maxLength', () => {
const valid_inputs = ['12🐍45', '1吉🏴😀', '', null];
const invalid_inputs = ['😀😀😀😀😀😀', '😀😀 😀😀😀', 'abcedf', '123456'];
assertValues(
'maxLength',
component,
valid_inputs,
invalid_inputs
);
});
});
上記のテストコードをng test
コマンドで実行すると作成したバリデーターが正しく動くことがわかります。
Directiveの作成
続いて、input
要素のmaxlength
属性の絵文字対応バージョンを作成するために、指定文字数以上を入力したら、削除するようなDirectiveを作成します。
surrogate-pair-max-length.directive.ts
import { length, substring } from 'stringz';
import {
Directive,
ElementRef,
HostListener,
Input,
Renderer2
} from '@angular/core';
@classSurrogatePairMaxLengthDirective
@Directive({
selector: '[appSurrogatePairMaxLength]'
})
export class SurrogatePairMaxLengthDirective {
@Input() public appSurrogatePairMaxLength: number;
constructor(private el: ElementRef, private renderer: Renderer2) {}
@param{*}
@HostListener('input', ['$event'])
public onChange(event: any): void {
if (length(event.target.value) > this.appSurrogatePairMaxLength) {
this.renderer.setProperty(
this.el.nativeElement,
'value',
substring(event.target.value, 0, this.appSurrogatePairMaxLength)
);
}
}
}
下記のコードでinput要素に文字を入力するたびに、指定文字数よりも長い文字列が入力された場合は、Renderer2
で余分な文字を削除した状態の文字列で置き換える、といった処理をしています。
@HostListener('input', ['$event'])
public onChange(event: any): void {
if (length(event.target.value) > this.appSurrogatePairMaxLength) {
this.renderer.setProperty(
this.el.nativeElement,
'value',
substring(event.target.value, 0, this.appSurrogatePairMaxLength)
);
}
}
上記のDirectiveを作成した後、下記のようなコードを書けば、3文字以上の文字を入力できないinput要素が完成します。
app.component.html
<input type="text" appSurrogatePairMaxLength="3">
参考サイト
GitHub - sallar/stringz: :100: Super fast unicode-aware string manipulation Javascript library
僕は、なぜ絵文字の長さが、直感に反するのか理解したい...!! - Qiita
A deep dive into Angular’s Renderer2.setValue method | by Konda Reddy Yaramala | JavaScript In Plain English | Medium
ionic2 - Ionic 3/Angular 2 - Renderer2.setValue() doesn't update the value of my input field - Stack Overflow