Skip to content

Commit

Permalink
feat(number-stepper): link error messages to input for assistive tool…
Browse files Browse the repository at this point in the history
…s (#1364)

* feat(number-stepper): link error messages

* fix: input number behavour

* fix: cleanup

* fix: cleanup

* fix: focusable buttons and group component

* fix: link error messages to input only

* fix: workound breaking changes

* fix: cleanup imports
  • Loading branch information
egarmel authored and GitHub Enterprise committed Dec 18, 2024
1 parent 158bed2 commit 1824f2b
Show file tree
Hide file tree
Showing 7 changed files with 97 additions and 49 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@
[(ngModel)]="number"
#stepperValidModel="ngModel"
step="2"
inputAriaLabel="Number of travellers"
></nx-number-stepper>
ariaDescribedBy="customHint"
>
<nx-label>Custom Label</nx-label>
@if (stepperValidModel.errors &&
stepperValidModel.errors.nxNumberStepperStepError) {
<nx-error> That's not a valid step </nx-error>
} @if (stepperValidModel.errors &&
stepperValidModel.errors.nxNumberStepperFormatError) {
<nx-error> That's not a valid number </nx-error>
}
</nx-number-stepper>

@if (!stepperValidModel.errors) {
<nx-message context="info" class="nx-margin-top-xs">
<nx-message context="info" class="nx-margin-top-s" id="customHint">
This is a hint. This message will disappear once you get an error from
parsing or when the input is missing altogether.
</nx-message>
} @if (stepperValidModel.errors &&
stepperValidModel.errors.nxNumberStepperStepError) {
<nx-error class="nx-margin-top-xs"> That's not a valid step </nx-error>
} @if (stepperValidModel.errors &&
stepperValidModel.errors.nxNumberStepperFormatError) {
<nx-error class="nx-margin-top-xs"> That's not a valid number </nx-error>
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NxErrorComponent } from '@aposin/ng-aquila/base';
import { NxErrorComponent, NxLabelComponent } from '@aposin/ng-aquila/base';
import { NxMessageComponent } from '@aposin/ng-aquila/message';
import { NxNumberStepperComponent } from '@aposin/ng-aquila/number-stepper';

Expand All @@ -17,6 +17,7 @@ import { NxNumberStepperComponent } from '@aposin/ng-aquila/number-stepper';
FormsModule,
NxMessageComponent,
NxErrorComponent,
NxLabelComponent,
],
})
export class NumberStepperValidationExampleComponent {
Expand Down
23 changes: 13 additions & 10 deletions projects/ng-aquila/src/number-stepper/number-stepper.component.html
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
@if (label) {
@if (label()) {
<div class="nx-stepper__label">
<label [for]="inputId">
{{ label }}
{{ label() }}
</label>
</div>
}
@if (!label) {
<div class="nx-stepper__label" #customLabel [attr.id]="ariaDescribedBy">
@if (!label()) {
<div class="nx-stepper__label" #customLabel [attr.id]="labelId">
<ng-content></ng-content>
</div>
}
<div class="nx-stepper__input-container">
<div class="nx-stepper__input-container" [class.error-spacing]="errorMessages()">
<button
[attr.aria-label]="decrementAriaLabel || _intl.decrementAriaLabel"
[nxButton]="_buttonType"
Expand All @@ -24,23 +24,24 @@
<div class="nx-stepper__input-wrapper">
<div class="nx-stepper__inner-wrapper">
<ng-content select="nx-number-stepper-prefix"></ng-content>

<input
type="text"
inputmode="decimal"
[nxAutoResize]="resize"
[attr.aria-describedby]="ariaDescribedBy"
[attr.aria-label]="inputAriaLabel"
[id]="inputId"
[ngClass]="inputClassNames"
(input)="onInputChange($event)"
(blur)="onTouchedCallback()"
(keydown.arrowup)="incrementOnKey($event)"
(keydown.arrowdown)="decrementOnKey($event)"
#nativeInput
spellcheck="false"
[disabled]="disabled"
[readonly]="readonlyInput"
[attr.aria-invalid]="errorMessages().length > 0 || null"
[attr.aria-describedby]="ariaDescribedByComputed()"
[attr.aria-label]="inputAriaLabel()"
[attr.aria-labelledby]="ariaLabelledByComputed()"
(keydown.arrowup)="incrementOnKey($event)"
(keydown.arrowdown)="decrementOnKey($event)"
/>

<ng-content select="nx-number-stepper-suffix"></ng-content>
Expand All @@ -59,3 +60,5 @@
<nx-icon name="plus" size="s" aria-hidden="true"></nx-icon>
</button>
</div>

<ng-content select="nx-error"></ng-content>
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,7 @@ $margin-default: nx-spacer(s);
}
}
}

.error-spacing {
margin-bottom: nx-spacer(s);
}
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,9 @@ describe('NxNumberStepperComponent', () => {
describe('programmatic change', () => {
it('should update on label change', () => {
createTestComponent(BasicStepper);
testInstance.stepperInstance.label = 'Programmatic label';

const basicStepperInstance = testInstance as BasicStepper;
basicStepperInstance.label = 'Programmatic label';
fixture.detectChanges();
expect(label.textContent!.trim()).toBe('Programmatic label');
});
Expand Down Expand Up @@ -686,11 +688,13 @@ describe('NxNumberStepperComponent', () => {
});

@Component({
template: `<nx-number-stepper label="Test"></nx-number-stepper>`,
template: `<nx-number-stepper [label]="label"></nx-number-stepper>`,
standalone: true,
imports: [NxNumberStepperModule, FormsModule, ReactiveFormsModule],
})
class BasicStepper extends NumberStepperTest {}
class BasicStepper extends NumberStepperTest {
label = 'Test';
}

@Component({
template: `<nx-number-stepper [(value)]="value"></nx-number-stepper>`,
Expand Down
82 changes: 57 additions & 25 deletions projects/ng-aquila/src/number-stepper/number-stepper.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,24 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
contentChildren,
effect,
ElementRef,
EventEmitter,
forwardRef,
Inject,
Input,
input,
LOCALE_ID,
OnDestroy,
Output,
Renderer2,
ViewChild,
viewChild,
} from '@angular/core';
import { ControlValueAccessor, FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validator } from '@angular/forms';
import { NxErrorComponent } from '@aposin/ng-aquila/base';
import { NxButtonModule } from '@aposin/ng-aquila/button';
import { MappedStyles } from '@aposin/ng-aquila/core';
import { NxIconModule } from '@aposin/ng-aquila/icon';
Expand Down Expand Up @@ -80,16 +86,47 @@ export class NxNumberStepperComponent extends MappedStyles implements AfterViewI
inputClassNames: string = mapClassNames('regular', INPUT_CLASSES);

/** @docs-private */
inputId = `nx-number-stepper-${nextUniqueId++}`;
private readonly _componentId = nextUniqueId++;
/** @docs-private */
readonly inputId = `nx-number-stepper-${this._componentId}`;

/** @docs-private */
inputWidth!: number;
readonly labelId = `nx-number-stepper-label-${this._componentId}`;

projectedLabel = viewChild(HTMLLabelElement);

/** @docs-private */
ariaDescribedBy: string | null = null;
inputWidth!: number;

ariaDescribedBy = input<string | null>(null);

ariaDescribedByComputed = computed<string | null>(() => {
// This is a workaround to not cause a breaking change for projected labels that are not wrapped in a label element.
// if there is a projected label without a html label, we don't want to add the label id to the described by
// should be removed with the next major version
const hasProjectedLabelWithoutHtmlLabel = this.projectedLabelWrapper() && !this.projectedLabel();

const hasDescribedBy = !!this.ariaDescribedBy() || this.errorMessages().length > 0;
if (!hasDescribedBy && !hasProjectedLabelWithoutHtmlLabel) {
return null;
}

const describedByIds = [...this.errorMessages().map(item => item.id), this.ariaDescribedBy()];

// Still the workaround for the projected label without html label
// should be removed with the next major version
if (hasProjectedLabelWithoutHtmlLabel) {
describedByIds.push(this.labelId);
}

return describedByIds.join(' ');
});

/** @docs-private */
@ViewChild('customLabel') ngContentWrapper!: ElementRef;
projectedLabelWrapper = viewChild<ElementRef>('customLabel');
projectedLabelElement = computed(() => this.projectedLabelWrapper()?.nativeElement.querySelector('label'));

errorMessages = contentChildren(NxErrorComponent);

/** @docs-private */
@ViewChild(NxAutoResizeDirective, { static: true }) autoResize!: NxAutoResizeDirective;
Expand All @@ -110,17 +147,7 @@ export class NxNumberStepperComponent extends MappedStyles implements AfterViewI
}
private _resize = false;

/** Defines the the label shown above the stepper input. */
@Input() set label(value: string) {
if (this._label !== value) {
this._label = value;
this._cdr.markForCheck();
}
}
get label(): string {
return this._label;
}
private _label!: string;
label = input<string | null>(null);

/** Sets the aria-label for the increment button. */
@Input() set incrementAriaLabel(value: string) {
Expand All @@ -141,13 +168,13 @@ export class NxNumberStepperComponent extends MappedStyles implements AfterViewI
private _decrementAriaLabel = '';

/** Sets the aria-label for the input of the number stepper. */
@Input() set inputAriaLabel(value: string) {
this._inputAriaLabel = value;
}
get inputAriaLabel(): string {
return this._inputAriaLabel;
}
private _inputAriaLabel = '';
inputAriaLabel = input<string | null>(null);

ariaLabelledByComputed = computed(() => {
const hasLabelValue = !!this.label();
const hasProjectedLabel = this.projectedLabelWrapper()?.nativeElement?.children?.length > 0;
return hasLabelValue || hasProjectedLabel ? this.labelId : null;
});

/** Sets the step size. Default: 1 */
@Input() set step(value: NumberInput) {
Expand Down Expand Up @@ -257,12 +284,15 @@ export class NxNumberStepperComponent extends MappedStyles implements AfterViewI
this._intl.changes.pipe(takeUntil(this._destroyed)).subscribe(() => this._cdr.markForCheck());

this.decimalSeperator = this.getDecimalSeparator(this.localeId);

effect(() => {
if (this.projectedLabelElement()) {
this.projectedLabelElement()!.setAttribute('for', this.inputId);
}
});
}

ngAfterViewInit(): void {
if (this.ngContentWrapper) {
this.ariaDescribedBy = this.ngContentWrapper.nativeElement.children.length > 0 ? `label-for-${this.inputId}` : '';
}
this.setInputValue(this._value);
}

Expand Down Expand Up @@ -387,6 +417,7 @@ export class NxNumberStepperComponent extends MappedStyles implements AfterViewI
} else {
newValue = this.enforceLimits(this._value || 0);
}

this.value = newValue;
this.setInputValue(this.value);
this.valueChange.emit(this._value!);
Expand Down Expand Up @@ -421,6 +452,7 @@ export class NxNumberStepperComponent extends MappedStyles implements AfterViewI
} else {
newValue = this.enforceLimits(this._value || 0);
}

this.value = newValue;
this.setInputValue(this.value);
this.valueChange.emit(this._value!);
Expand Down
3 changes: 2 additions & 1 deletion projects/ng-aquila/src/number-stepper/number-stepper.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ This component has two sizes - big and normal.

#### Custom label

You can use custom markup by omiting the nxLabel attribute and provide your markup inside the element, e.g. to show a tooltip. If nxLabel is set the custom content will not be rendered!
You can use custom markup by omitting the `label` attribute and provide your markup inside the element, e.g. to show a tooltip. If `label` is set the custom content will not be rendered.
Use a `<nx-label>` or `<label>` inside your custom markup. That way the label will be automatically connected to the input field via `for` to improve accessibility.

<!-- example(number-stepper-custom-label) -->

Expand Down

0 comments on commit 1824f2b

Please sign in to comment.