tolerated value input
This commit is contained in:
parent
c728a5e096
commit
36d88304c3
@ -0,0 +1,18 @@
|
||||
<div role="group" class="tolerated-value-input-container" [formGroup]="parts"
|
||||
[attr.aria-labelledby]="_formField?.getLabelId()" (focusin)="onFocusIn()" (focusout)="onFocusOut($event)">
|
||||
<input class="tolerated-value-input-element value" formControlName="value" type="number" aria-label="Area code"
|
||||
value />
|
||||
<span class="unit">{{unit()}}</span>
|
||||
|
||||
@if (tolerated()) {
|
||||
<span class="prefix">+</span>
|
||||
<input class="tolerated-value-input-element" formControlName="plus" type="number" aria-label="Exchange code"
|
||||
#plus />
|
||||
<span class="unit">{{unit()}}</span>
|
||||
|
||||
<span class="prefix">-</span>
|
||||
<input class="tolerated-value-input-element" formControlName="minus" type="number" aria-label="Subscriber number"
|
||||
#minus />
|
||||
<span class="unit">{{unit()}}</span>
|
||||
}
|
||||
</div>
|
||||
@ -0,0 +1,59 @@
|
||||
.tolerated-value-input-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.tolerated-value-input-element {
|
||||
// border: none;
|
||||
// background: none;
|
||||
// padding: 0;
|
||||
// outline: none;
|
||||
// font: inherit;
|
||||
// text-align: center;
|
||||
// color: currentcolor;
|
||||
|
||||
width: 2rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.value {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.unit {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.prefix {
|
||||
margin-left: 0.5rem;
|
||||
color: red;
|
||||
font-weight: 500;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.tolerated-value-input-spacer,
|
||||
.unit,
|
||||
.prefix {
|
||||
opacity: 0;
|
||||
transition: opacity 200ms;
|
||||
}
|
||||
|
||||
:host.floating {
|
||||
.tolerated-value-input-spacer,
|
||||
.unit,
|
||||
.prefix {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ToleratedValueInputComponent } from './tolerated-value-input.component';
|
||||
|
||||
describe('ToleratedValueInputComponent', () => {
|
||||
let component: ToleratedValueInputComponent;
|
||||
let fixture: ComponentFixture<ToleratedValueInputComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ToleratedValueInputComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ToleratedValueInputComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
295
src/app/tolerated-value-input/tolerated-value-input.component.ts
Normal file
295
src/app/tolerated-value-input/tolerated-value-input.component.ts
Normal file
@ -0,0 +1,295 @@
|
||||
import { FocusMonitor } from '@angular/cdk/a11y';
|
||||
import { booleanAttribute, Component, computed, effect, ElementRef, inject, input, model, OnDestroy, signal, untracked, viewChild } from '@angular/core';
|
||||
import { AbstractControl, ControlValueAccessor, FormControl, FormGroup, NgControl, ReactiveFormsModule, ValidationErrors, Validator, Validators } from '@angular/forms';
|
||||
import { MAT_FORM_FIELD, MatFormFieldControl } from '@angular/material/form-field';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
const CONTROL_TYPE_IDENTIFIER = 'tolerated-value';
|
||||
|
||||
export class ToleratedValue {
|
||||
constructor(
|
||||
public value: number | null,
|
||||
public plus: number | null,
|
||||
public minus: number | null,
|
||||
) { }
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-tolerated-value-input',
|
||||
imports: [ReactiveFormsModule],
|
||||
templateUrl: './tolerated-value-input.component.html',
|
||||
styleUrl: './tolerated-value-input.component.scss',
|
||||
providers: [{ provide: MatFormFieldControl, useExisting: ToleratedValueInputComponent }],
|
||||
host: {
|
||||
'[class.floating]': 'shouldLabelFloat',
|
||||
'[id]': 'id',
|
||||
},
|
||||
})
|
||||
export class ToleratedValueInputComponent implements ControlValueAccessor, Validator, MatFormFieldControl<ToleratedValue>, OnDestroy {
|
||||
|
||||
static nextId = 0;
|
||||
readonly valueInput = viewChild.required<HTMLInputElement>('value');
|
||||
readonly plusInput = viewChild.required<HTMLInputElement>('plus');
|
||||
readonly minusInput = viewChild.required<HTMLInputElement>('minus');
|
||||
ngControl = inject(NgControl, { optional: true, self: true });
|
||||
|
||||
readonly unit = input<string>("mm");
|
||||
readonly tolerated = input<boolean, unknown>(true, {
|
||||
alias: 'tolerated',
|
||||
transform: booleanAttribute,
|
||||
});
|
||||
|
||||
readonly parts: FormGroup<{
|
||||
value: FormControl<number | null>;
|
||||
plus: FormControl<number | null>;
|
||||
minus: FormControl<number | null>;
|
||||
}>;
|
||||
|
||||
readonly stateChanges = new Subject<void>();
|
||||
private partsChangedSubscription: Subscription | null = null;
|
||||
readonly touched = signal(false);
|
||||
readonly controlType = CONTROL_TYPE_IDENTIFIER;
|
||||
readonly id = `${CONTROL_TYPE_IDENTIFIER}-${ToleratedValueInputComponent.nextId++}`;
|
||||
readonly _userAriaDescribedBy = input<string>('', { alias: 'aria-describedby' });
|
||||
readonly _placeholder = input<string>('', { alias: 'placeholder' });
|
||||
readonly _required = input<boolean, unknown>(false, {
|
||||
alias: 'required',
|
||||
transform: booleanAttribute,
|
||||
});
|
||||
readonly _disabledByInput = input<boolean, unknown>(false, {
|
||||
alias: 'disabled',
|
||||
transform: booleanAttribute,
|
||||
});
|
||||
readonly _value = model<ToleratedValue | null>(null, { alias: 'value' });
|
||||
onChange = (_: any) => { };
|
||||
onTouched = () => { };
|
||||
|
||||
protected readonly _formField = inject(MAT_FORM_FIELD, {
|
||||
optional: true,
|
||||
});
|
||||
|
||||
private readonly _focused = signal(false);
|
||||
private readonly _disabledByCva = signal(false);
|
||||
private readonly _disabled = computed(() => this._disabledByInput() || this._disabledByCva());
|
||||
private readonly _focusMonitor = inject(FocusMonitor);
|
||||
private readonly _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
get focused(): boolean {
|
||||
return this._focused();
|
||||
}
|
||||
|
||||
get empty() {
|
||||
const {
|
||||
value: { value, plus, minus },
|
||||
} = this.parts;
|
||||
|
||||
return !value && !plus && !minus;
|
||||
}
|
||||
|
||||
get shouldLabelFloat() {
|
||||
return this.focused || !this.empty;
|
||||
}
|
||||
|
||||
get userAriaDescribedBy() {
|
||||
return this._userAriaDescribedBy();
|
||||
}
|
||||
|
||||
get placeholder(): string {
|
||||
return this._placeholder();
|
||||
}
|
||||
|
||||
get required(): boolean {
|
||||
return this._required() || (this.ngControl?.control?.hasValidator(Validators.required) ?? false);
|
||||
}
|
||||
|
||||
get disabled(): boolean {
|
||||
return this._disabled();
|
||||
}
|
||||
|
||||
get value(): ToleratedValue | null {
|
||||
return this._value();
|
||||
}
|
||||
|
||||
get errorState(): boolean {
|
||||
return this.parts.invalid && this.touched();
|
||||
}
|
||||
|
||||
constructor() {
|
||||
if (this.ngControl != null) {
|
||||
this.ngControl.valueAccessor = this;
|
||||
}
|
||||
|
||||
this.parts = new FormGroup({
|
||||
value: new FormControl<number | null>(null),
|
||||
plus: new FormControl<number | null>(null),
|
||||
minus: new FormControl<number | null>(null),
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
// Read signals to trigger effect.
|
||||
this._placeholder();
|
||||
this._required();
|
||||
this._disabled();
|
||||
this._focused();
|
||||
// Propagate state changes.
|
||||
untracked(() => this.stateChanges.next());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (this._disabled()) {
|
||||
untracked(() => this.parts.disable());
|
||||
} else {
|
||||
untracked(() => this.parts.enable());
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (this.tolerated()) {
|
||||
this.parts.controls.plus.enable();
|
||||
this.parts.controls.minus.enable();
|
||||
} else {
|
||||
this.parts.controls.plus.disable();
|
||||
this.parts.controls.minus.disable();
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const value = this._value() || new ToleratedValue(null, null, null);
|
||||
untracked(() => this.parts.setValue(value));
|
||||
});
|
||||
|
||||
this.parts.statusChanges.pipe(takeUntilDestroyed()).subscribe(() => {
|
||||
this.stateChanges.next();
|
||||
});
|
||||
|
||||
// this.parts.valueChanges.pipe(takeUntilDestroyed()).pipe(debounceTime(500)).subscribe(value => {
|
||||
// console.log("Update triggered", value)
|
||||
// // const toleratedValue = this.parts.valid
|
||||
// // ? new ToleratedValue(
|
||||
// // this.parts.value.value || null,
|
||||
// // this.parts.value.plus || null,
|
||||
// // this.parts.value.minus || null,
|
||||
// // )
|
||||
// // : null;
|
||||
// const toleratedValue = this.parts.valid ? new ToleratedValue(
|
||||
// this.parts.value.value ?? null,
|
||||
// this.parts.value.plus ?? null,
|
||||
// this.parts.value.minus ?? null,
|
||||
// ) : null;
|
||||
// this._updateValue(toleratedValue);
|
||||
// });
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.partsChangedSubscription = this.parts.valueChanges.subscribe(value => {
|
||||
const toleratedValue = this.parts.valid ? new ToleratedValue(
|
||||
this.parts.value.value ?? null,
|
||||
this.parts.value.plus ?? null,
|
||||
this.parts.value.minus ?? null,
|
||||
) : null;
|
||||
this._updateValue(toleratedValue);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.stateChanges.complete();
|
||||
this.partsChangedSubscription?.unsubscribe();
|
||||
this._focusMonitor.stopMonitoring(this._elementRef);
|
||||
}
|
||||
|
||||
onFocusIn() {
|
||||
if (!this._focused()) {
|
||||
this._focused.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
onFocusOut(event: FocusEvent) {
|
||||
if (!this._elementRef.nativeElement.contains(event.relatedTarget as Element)) {
|
||||
this.touched.set(true);
|
||||
this._focused.set(false);
|
||||
this.onTouched();
|
||||
}
|
||||
}
|
||||
|
||||
// autoFocusNext(control: AbstractControl, nextElement?: HTMLInputElement): void {
|
||||
// if (!control.errors && nextElement) {
|
||||
// this._focusMonitor.focusVia(nextElement, 'program');
|
||||
// }
|
||||
// }
|
||||
|
||||
// autoFocusPrev(control: AbstractControl, prevElement: HTMLInputElement): void {
|
||||
// if (control.value.length < 1) {
|
||||
// this._focusMonitor.focusVia(prevElement, 'program');
|
||||
// }
|
||||
// }
|
||||
|
||||
setDescribedByIds(ids: string[]) {
|
||||
const controlElement = this._elementRef.nativeElement.querySelector(
|
||||
`.tolerated-value-input-container`,
|
||||
)!;
|
||||
controlElement.setAttribute('aria-describedby', ids.join(' '));
|
||||
}
|
||||
|
||||
onContainerClick(event: MouseEvent) {
|
||||
// this.ngControl?.control?.markAllAsTouched();
|
||||
// this.ngControl?.control?.markAllAsDirty();
|
||||
// this.ngControl?.control?.setErrors({ plus: true })
|
||||
|
||||
if ((event.target as Element).tagName.toLowerCase() != 'input') {
|
||||
// this._elementRef.nativeElement.querySelector('input').focus();
|
||||
this._focusMonitor.focusVia(this.valueInput(), 'program');
|
||||
}
|
||||
|
||||
// if (this.parts.controls.subscriber.valid) {
|
||||
// this._focusMonitor.focusVia(this.subscriberInput(), 'program');
|
||||
// } else if (this.parts.controls.exchange.valid) {
|
||||
// this._focusMonitor.focusVia(this.subscriberInput(), 'program');
|
||||
// } else if (this.parts.controls.area.valid) {
|
||||
// this._focusMonitor.focusVia(this.exchangeInput(), 'program');
|
||||
// } else {
|
||||
// this._focusMonitor.focusVia(this.areaInput(), 'program');
|
||||
// }
|
||||
}
|
||||
|
||||
validate(control: AbstractControl): ValidationErrors | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
writeValue(toleratedValue: ToleratedValue | null): void {
|
||||
this._updateValue(toleratedValue);
|
||||
}
|
||||
|
||||
registerOnChange(fn: any): void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
|
||||
registerOnTouched(fn: any): void {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this._disabledByCva.set(isDisabled);
|
||||
}
|
||||
|
||||
// _handleInput(): void {
|
||||
// // this.autoFocusNext(control, nextElement);
|
||||
// // this.onChange(this.value);
|
||||
// }
|
||||
|
||||
private _updateValue(toleratedValue: ToleratedValue | null, fromWriteValue = false) {
|
||||
const current = this._value();
|
||||
if (
|
||||
toleratedValue === current ||
|
||||
(toleratedValue?.value === current?.value &&
|
||||
toleratedValue?.plus === current?.plus &&
|
||||
toleratedValue?.minus === current?.minus)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._value.set(toleratedValue);
|
||||
this.onChange(toleratedValue)
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user