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