Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Form field: announce error text when nested interactive is focussed #3746

Merged
merged 19 commits into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion libs/designsystem/dropdown/src/dropdown.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import { IconModule } from '@kirbydesign/designsystem/icon';
import { ItemModule } from '@kirbydesign/designsystem/item';
import { PopoverComponent } from '@kirbydesign/designsystem/popover';

import { ListItemTemplateDirective, ListModule } from '@kirbydesign/designsystem/list';
import { DropdownComponent } from './dropdown.component';
import { KeyboardHandlerService } from './keyboard-handler.service';
import { ListItemTemplateDirective, ListModule } from '@kirbydesign/designsystem/list';

const declarations = [DropdownComponent];

Expand Down
33 changes: 24 additions & 9 deletions libs/designsystem/form-field/src/form-field.component.html
Original file line number Diff line number Diff line change
@@ -1,24 +1,39 @@
<label *ngIf="_wrapContentInLabel">
<ng-container *ngTemplateOutlet="labelTextTemplate"></ng-container>
<ng-container *ngTemplateOutlet="slottedInputTemplate"></ng-container>

<!-- add error message inside label if nested interative is in error state -->
<ng-container *ngIf="_nestedInteractiveHasError">
<ng-container *ngTemplateOutlet="messageTemplate"></ng-container>
</ng-container>
</label>

<!-- add message outside label if nested interative is in valid state -->
<ng-container *ngIf="!_nestedInteractiveHasError && _wrapContentInLabel">
<ng-container *ngTemplateOutlet="messageTemplate"></ng-container>
</ng-container>

<ng-container *ngIf="!_wrapContentInLabel">
<ng-container *ngTemplateOutlet="labelTemplate"></ng-container>
<ng-container *ngTemplateOutlet="slottedInputTemplate"></ng-container>
<ng-container *ngTemplateOutlet="messageTemplate"></ng-container>
</ng-container>

<div *ngIf="message !== undefined || counter" class="texts">
<kirby-form-field-message
*ngIf="message !== undefined"
class="message"
[text]="message"
></kirby-form-field-message>
<ng-template #messageTemplate>
<div *ngIf="message !== undefined || counter" class="texts">
<kirby-form-field-message
*ngIf="message !== undefined"
class="message"
[text]="message"
[attr.aria-live]="_nestedInteractiveHasError ? 'polite' : null"
[attr.id]="_nestedInteractiveHasError ? _errorMessageId : _messageId"
></kirby-form-field-message>

<div *ngIf="counter" class="counter">
<ng-content select="kirby-input-counter"></ng-content>
<div *ngIf="counter" class="counter" aria-hidden="true">
<ng-content select="kirby-input-counter"></ng-content>
</div>
</div>
</div>
</ng-template>

<ng-template #slottedInputTemplate>
<div class="affix-container">
Expand Down
170 changes: 144 additions & 26 deletions libs/designsystem/form-field/src/form-field.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,143 @@ describe('FormFieldComponent', () => {
});
});

describe('and slotted input', () => {
let inputElement: HTMLInputElement;
let messageElement: HTMLElement;

beforeEach(() => {
spectator = createHost(
`<kirby-form-field message="My Message" [label]="label">
<input kirby-input [hasError]="hasError" />
</kirby-form-field>`,
{ hostProps: { hasError: false, label: '' } }
);
inputElement = spectator.queryHost('input[kirby-input]');
messageElement = spectator.queryHost('kirby-form-field-message');
});

it('should set aria-describedby on input to message id', () => {
expect(inputElement).toHaveAttribute('aria-describedby', messageElement.id);
});

it('should set error-specific aria attributes on input', () => {
spectator.setHostInput({ hasError: true });

expect(inputElement).toHaveAttribute('aria-invalid', 'true');
expect(inputElement).toHaveAttribute('aria-errormessage', messageElement.id);
});

it('should not place message inside label when hasError on input is false', () => {
spectator.setHostInput({ label: 'My Label' });

const parentLabel = messageElement.closest('label');

expect(parentLabel).toBeNull();
});

it('should place message inside label when hasError on input is true', () => {
spectator.setHostInput({ label: 'My Label' });
spectator.setHostInput({ hasError: true });

const parentLabel = messageElement.closest('label');

expect(parentLabel).toBeDefined();
});
});

describe('and slotted textarea', () => {
let textareaElement: HTMLInputElement;
let messageElement: HTMLElement;

beforeEach(() => {
spectator = createHost(
`<kirby-form-field message="My Message" [label]="label">
<textarea kirby-textarea [hasError]="hasError"></textarea>
</kirby-form-field>`,
{ hostProps: { hasError: false, label: '' } }
);
textareaElement = spectator.queryHost('textarea[kirby-textarea]');
messageElement = spectator.queryHost('kirby-form-field-message');
});

it('should set aria-describedby on input to message id', () => {
expect(textareaElement).toHaveAttribute('aria-describedby', messageElement.id);
});

it('should set error-specific aria attributes on input', () => {
spectator.setHostInput({ hasError: true });

expect(textareaElement).toHaveAttribute('aria-invalid', 'true');
expect(textareaElement).toHaveAttribute('aria-errormessage', messageElement.id);
});

it('should not place message inside label when hasError on input is false', () => {
spectator.setHostInput({ label: 'My Label' });

const parentLabel = messageElement.closest('label');

expect(parentLabel).toBeNull();
});

it('should place message inside label when hasError on input is true', () => {
spectator.setHostInput({ label: 'My Label' });
spectator.setHostInput({ hasError: true });

const parentLabel = messageElement.closest('label');

expect(parentLabel).toBeDefined();
});
});

describe('and slotted radio group', () => {
let ionRadioGroup: HTMLInputElement;
let messageElement: HTMLElement;

beforeEach(() => {
spectator = createHost(
`<kirby-form-field message="My Message" [label]="label">
<kirby-radio-group [hasError]="hasError" value="Bacon">
<kirby-radio value="Bacon" text="Bacon"></kirby-radio>
<kirby-radio value="Bologna" text="Bologna"></kirby-radio>
<kirby-radio value="Tenderloin" text="Tenderloin"></kirby-radio>
</kirby-radio-group>
</kirby-form-field>`,
{ hostProps: { hasError: false, label: '' } }
);
ionRadioGroup = spectator.queryHost('ion-radio-group');
messageElement = spectator.queryHost('kirby-form-field-message');
});

it('should set aria-describedby on input to message id', () => {
spectator.detectChanges();
expect(ionRadioGroup).toHaveAttribute('aria-describedby', messageElement.id);
});

it('should set error-specific aria attributes on input', () => {
spectator.setHostInput({ hasError: true });

expect(ionRadioGroup).toHaveAttribute('aria-invalid', 'true');
expect(ionRadioGroup).toHaveAttribute('aria-errormessage', messageElement.id);
});

it('should not place message inside label when hasError on input is false', () => {
spectator.setHostInput({ label: 'My Label' });

const parentLabel = messageElement.closest('label');

expect(parentLabel).toBeNull();
});

it('should place message inside label when hasError on input is true', () => {
spectator.setHostInput({ label: 'My Label' });
spectator.setHostInput({ hasError: true });

const parentLabel = messageElement.closest('label');

expect(parentLabel).toBeDefined();
});
});

describe('when having a counter', () => {
let messageElement: HTMLElement;
let counterWrapperElement: HTMLElement;
Expand Down Expand Up @@ -230,9 +367,9 @@ describe('FormFieldComponent', () => {

spectator = createHost(
`<kirby-form-field>
<input kirby-input [readonly]="readonly" />
<input kirby-input />
</kirby-form-field>`,
{ detectChanges: false, hostProps: { readonly: false } } // Delay change detection to allow altering platform.isTouch()
{ detectChanges: false } // Delay change detection to allow altering platform.isTouch()
);

spectator.detectChanges();
Expand All @@ -255,7 +392,6 @@ describe('FormFieldComponent', () => {
});

it('should register shims', () => {
spectator.setHostInput({ readonly: false });
spectator.detectChanges(); //ngOnInit() + 1st ngAfterContentChecked()
expect(dispatchEventSpy).toHaveBeenCalledTimes(1);
expect(dispatchEventSpy).toHaveBeenCalledWith(
Expand All @@ -265,27 +401,6 @@ describe('FormFieldComponent', () => {
);
});

it('should NOT register shims if readonly', () => {
spectator.setHostInput({ readonly: true });
spectator.detectChanges(); //ngOnInit() + 1st ngAfterContentChecked()
expect(dispatchEventSpy).toHaveBeenCalledTimes(0);
});

it('should register shims if changing from readonly to not readonly', () => {
spectator.setHostInput({ readonly: true });
spectator.detectChanges(); //ngOnInit() + 1st ngAfterContentChecked()
expect(dispatchEventSpy).toHaveBeenCalledTimes(0);

spectator.setHostInput({ readonly: false });
spectator.detectChanges(); //ngOnInit() + 2nd ngAfterContentChecked()
expect(dispatchEventSpy).toHaveBeenCalledTimes(1);
expect(dispatchEventSpy).toHaveBeenCalledWith(
new CustomEvent('ionInputDidLoad', {
detail: spectator.element,
})
);
});

it('should dispatch `ionInputDidUnload` event on destroy', () => {
spectator.fixture.destroy();
const event: Event = dispatchEventSpy.calls.mostRecent().args[0];
Expand Down Expand Up @@ -426,8 +541,11 @@ describe('FormFieldComponent', () => {
expect(label).toBeTruthy();
});

it('should associate the label with the radio group', () => {
expect(radioGroupElement.getAttribute('aria-labelledby')).toEqual(label.id);
it('should associate the label with the radio group', async () => {
const ionRadioGroupElement = radioGroupElement.querySelector('ion-radio-group');
spectator.detectChanges();

expect(ionRadioGroupElement.getAttribute('aria-labelledby')).toEqual(label.id);
});

it('should focus the the radio group when clicking the label ', () => {
Expand Down
Loading
Loading