tolerated value input

This commit is contained in:
Hlars 2025-11-16 21:02:30 +01:00
parent c728a5e096
commit 36d88304c3
4 changed files with 395 additions and 0 deletions

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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();
});
});

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