diff --git a/src/app/tolerated-value-input/tolerated-value-input.component.html b/src/app/tolerated-value-input/tolerated-value-input.component.html
new file mode 100644
index 0000000..3f3760d
--- /dev/null
+++ b/src/app/tolerated-value-input/tolerated-value-input.component.html
@@ -0,0 +1,18 @@
+
+
+ {{unit()}}
+
+ @if (tolerated()) {
+ +
+
+ {{unit()}}
+
+ -
+
+ {{unit()}}
+ }
+
\ No newline at end of file
diff --git a/src/app/tolerated-value-input/tolerated-value-input.component.scss b/src/app/tolerated-value-input/tolerated-value-input.component.scss
new file mode 100644
index 0000000..47821e5
--- /dev/null
+++ b/src/app/tolerated-value-input/tolerated-value-input.component.scss
@@ -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;
+ }
+}
diff --git a/src/app/tolerated-value-input/tolerated-value-input.component.spec.ts b/src/app/tolerated-value-input/tolerated-value-input.component.spec.ts
new file mode 100644
index 0000000..91bfd9c
--- /dev/null
+++ b/src/app/tolerated-value-input/tolerated-value-input.component.spec.ts
@@ -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;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ToleratedValueInputComponent]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(ToleratedValueInputComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/tolerated-value-input/tolerated-value-input.component.ts b/src/app/tolerated-value-input/tolerated-value-input.component.ts
new file mode 100644
index 0000000..b184922
--- /dev/null
+++ b/src/app/tolerated-value-input/tolerated-value-input.component.ts
@@ -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, OnDestroy {
+
+ static nextId = 0;
+ readonly valueInput = viewChild.required('value');
+ readonly plusInput = viewChild.required('plus');
+ readonly minusInput = viewChild.required('minus');
+ ngControl = inject(NgControl, { optional: true, self: true });
+
+ readonly unit = input("mm");
+ readonly tolerated = input(true, {
+ alias: 'tolerated',
+ transform: booleanAttribute,
+ });
+
+ readonly parts: FormGroup<{
+ value: FormControl;
+ plus: FormControl;
+ minus: FormControl;
+ }>;
+
+ readonly stateChanges = new Subject();
+ private partsChangedSubscription: Subscription | null = null;
+ readonly touched = signal(false);
+ readonly controlType = CONTROL_TYPE_IDENTIFIER;
+ readonly id = `${CONTROL_TYPE_IDENTIFIER}-${ToleratedValueInputComponent.nextId++}`;
+ readonly _userAriaDescribedBy = input('', { alias: 'aria-describedby' });
+ readonly _placeholder = input('', { alias: 'placeholder' });
+ readonly _required = input(false, {
+ alias: 'required',
+ transform: booleanAttribute,
+ });
+ readonly _disabledByInput = input(false, {
+ alias: 'disabled',
+ transform: booleanAttribute,
+ });
+ readonly _value = model(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);
+
+ 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(null),
+ plus: new FormControl(null),
+ minus: new FormControl(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)
+ }
+
+}