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) + } + +}