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

Properly describe form validation errors #166

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
63 changes: 39 additions & 24 deletions packages/core/src/components/input/input.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { newSpecPage } from '@stencil/core/testing';
import { InputComponent } from './input';

let inputId = 1;
let errorId = 1;

describe('admiralty-input', () => {
it('renders', async () => {
const page = await newSpecPage({
Expand All @@ -10,14 +13,16 @@ describe('admiralty-input', () => {
expect(page.root).toEqualHtml(`
<admiralty-input>
<div class="text-input-container">
<input autocomplete="off" id="admiralty-input-1" name="admiralty-input-1" type="text" value="">
<admiralty-input-invalid style="visibility: hidden;"></admiralty-input-invalid>
<input aria-describedby="null null" aria-invalid="false" autocomplete="off" id="admiralty-input-${inputId}" name="admiralty-input-1" type="text" value="">
<admiralty-input-invalid id="admiralty-input-error-${inputId}" style="visibility: hidden;"></admiralty-input-invalid>
</div>
</admiralty-input>
`);
});

it('renders with a label', async () => {
inputId++;
errorId++;
const page = await newSpecPage({
components: [InputComponent],
html: `<admiralty-input label="test-label"></admiralty-input>`,
Expand All @@ -26,87 +31,97 @@ describe('admiralty-input', () => {
<admiralty-input label="test-label">
<div class="text-input-container">
<admiralty-label for="admiralty-input-2">test-label</admiralty-label>
<input autocomplete="off" id="admiralty-input-2" name="admiralty-input-2" type="text" value="">
<admiralty-input-invalid style="visibility: hidden;"></admiralty-input-invalid>
<input aria-describedby="null null" aria-invalid="false" autocomplete="off" id="admiralty-input-${inputId}" name="admiralty-input-2" type="text" value="">
<admiralty-input-invalid id="admiralty-input-error-${inputId}" style="visibility: hidden;"></admiralty-input-invalid>
</div>
</admiralty-input>
`);
});

it('renders disabled', async () => {
inputId++;
errorId++;
const page = await newSpecPage({
components: [InputComponent],
html: `<admiralty-input disabled></admiralty-input>`,
});
expect(page.root).toEqualHtml(`
<admiralty-input disabled>
<div class="text-input-container">
<input disabled autocomplete="off" class="disabled" id="admiralty-input-3" name="admiralty-input-3" type="text" value="">
<admiralty-input-invalid style="visibility: hidden;"></admiralty-input-invalid>
<input aria-describedby="null null" aria-invalid="false" disabled autocomplete="off" class="disabled" id="admiralty-input-${inputId}" name="admiralty-input-3" type="text" value="">
<admiralty-input-invalid id="admiralty-input-error-${inputId}" style="visibility: hidden;"></admiralty-input-invalid>
</div>
</admiralty-input>
`);
});

it('renders invalid even without invalidMessage', async () => {
inputId++;
errorId++;
const page = await newSpecPage({
components: [InputComponent],
html: `<admiralty-input invalid="true"></admiralty-input>`,
});
expect(page.root).toEqualHtml(`
<admiralty-input invalid="true">
<div class="text-input-container">
<input autocomplete="off" class="invalid" id="admiralty-input-4" name="admiralty-input-4" type="text" value="">
<admiralty-input-invalid style="visibility: hidden;"></admiralty-input-invalid>
<input aria-describedby="null admiralty-input-error-4" aria-invalid="true" autocomplete="off" class="invalid" id="admiralty-input-${inputId}" name="admiralty-input-4" type="text" value="">
<admiralty-input-invalid id="admiralty-input-error-${inputId}" style="visibility: hidden;"></admiralty-input-invalid>
</div>
</admiralty-input>
`);
});

it('renders invalid with invalidMessage', async () => {
inputId++;
errorId++;
const page = await newSpecPage({
components: [InputComponent],
html: `<admiralty-input invalid="true" invalid-message="This is invalid!"></admiralty-input>`,
});
expect(page.root).toEqualHtml(`
<admiralty-input invalid="true" invalid-message="This is invalid!">
<div class="text-input-container">
<input autocomplete="off" class="invalid" id="admiralty-input-5" name="admiralty-input-5" type="text" value="">
<admiralty-input-invalid style="visibility: visible;">
This is invalid!
</admiralty-input-invalid>
</div>
<div class="text-input-container">
<input aria-describedby="null admiralty-input-error-5" aria-invalid="true" autocomplete="off" class="invalid" id="admiralty-input-${inputId}" name="admiralty-input-5" type="text" value="">
<admiralty-input-invalid id="admiralty-input-error-${inputId}" style="visibility: visible;">
This is invalid!
</admiralty-input-invalid>
</div>
</admiralty-input>
`);
`);
});

it('renders with type', async () => {
inputId++;
errorId++;
const page = await newSpecPage({
components: [InputComponent],
html: `<admiralty-input type="date"></admiralty-input>`,
});
expect(page.root).toEqualHtml(`
<admiralty-input type="date">
<div class="text-input-container">
<input type="date" autocomplete="off" id="admiralty-input-6" name="admiralty-input-6" value="">
<admiralty-input-invalid style="visibility: hidden;"></admiralty-input-invalid>
<input aria-describedby="null null" aria-invalid="false" type="date" autocomplete="off" id="admiralty-input-${inputId}" name="admiralty-input-6" value="">
<admiralty-input-invalid id="admiralty-input-error-${inputId}" style="visibility: hidden;"></admiralty-input-invalid>
</div>
</admiralty-input>
`);
});
});

it('renders with maxlength', async () => {
inputId++
errorId++;
const page = await newSpecPage({
components: [InputComponent],
html: `<admiralty-input max-length="1"></admiralty-input>`,
});
expect(page.root).toEqualHtml(`
<admiralty-input max-length="1">
<div class="text-input-container">
<input autocomplete="off" class="" id="admiralty-input-7" maxlength="1" name="admiralty-input-7" type="text" value="">
<admiralty-input-invalid style="visibility: hidden;"></admiralty-input-invalid>
</div>
</admiralty-input>
`);
<admiralty-input max-length="1">
<div class="text-input-container">
<input autocomplete="off" aria-describedby="null null" aria-invalid="false" id="admiralty-input-${inputId}" maxlength="1" name="admiralty-input-7" type="text" value="">
<admiralty-input-invalid id="admiralty-input-error-${inputId}" style="visibility: hidden;"></admiralty-input-invalid>
</div>
</admiralty-input>
`);
});
20 changes: 17 additions & 3 deletions packages/core/src/components/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import { InputChangeEventDetail } from './input.interface';
scoped: true,
})
export class InputComponent implements ComponentInterface {
inputId: string = `admiralty-input-${++nextId}`;
private id = ++nextId;
inputId: string = `admiralty-input-${this.id}`;
hintId: string = `admiralty-input-hint-${this.id}`;
errorId: string = `admiralty-input-error-${this.id}`;

private nativeInput?: HTMLInputElement;

Expand Down Expand Up @@ -91,6 +94,7 @@ export class InputComponent implements ComponentInterface {
/**
* Update the native input element when the value changes
*/

@Watch('value')
protected valueChanged() {
const nativeInput = this.nativeInput;
Expand All @@ -114,14 +118,22 @@ export class InputComponent implements ComponentInterface {

render() {
const value = this.getValue();

return (
<div class="text-input-container">
{this.label ? (
<admiralty-label disabled={this.disabled} for={this.inputId}>
{this.label}
</admiralty-label>
) : null}
{this.hint ? <admiralty-hint disabled={this.disabled}>{this.hint}</admiralty-hint> : null}
{this.hint ? (
<admiralty-hint
id={this.hintId}
disabled={this.disabled}
>
{this.hint}
</admiralty-hint>
) : null}
<input
ref={input => (this.nativeInput = input)}
class={{ disabled: this.disabled, invalid: this.invalid }}
Expand All @@ -138,8 +150,10 @@ export class InputComponent implements ComponentInterface {
style={{
maxWidth: this.width ? `${this.width}px` : null,
}}
aria-invalid={this.invalid ? 'true' : 'false'}
aria-describedby={(this.hint ? this.hintId : null) + ' ' + (this.invalid ? this.errorId : null)}
/>
<admiralty-input-invalid style={{ visibility: this.invalid && this.invalidMessage ? 'visible' : 'hidden' }}>{this.invalidMessage}</admiralty-input-invalid>
<admiralty-input-invalid id={this.errorId} style={{ visibility: this.invalid && this.invalidMessage ? 'visible' : 'hidden' }}>{this.invalidMessage}</admiralty-input-invalid>
</div>
);
}
Expand Down
15 changes: 9 additions & 6 deletions packages/core/src/components/textarea/textarea.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { newSpecPage } from '@stencil/core/testing';
import { TextareaComponent } from './textarea';

let compId = -1;
let compId = -0;
let errorId = 3;

describe('admiralty-textarea', () => {
it('renders', async () => {
Expand All @@ -21,7 +22,7 @@ describe('admiralty-textarea', () => {
<admiralty-hint>
Please enter description
</admiralty-hint>
<textarea id="admiralty-textarea-${compId}" value=""></textarea>
<textarea aria-describedby="admiralty-textarea-hint-1 null" aria-invalid="false" id="admiralty-textarea-${compId}" value=""></textarea>
<admiralty-input-invalid style="visibility: hidden;"></admiralty-input-invalid>
</div>
</admiralty-textarea>
Expand All @@ -41,7 +42,7 @@ describe('admiralty-textarea', () => {
expect(page.root).toEqualHtml(`
<admiralty-textarea value="${testText}">
<div class="text-area-container">
<textarea id="admiralty-textarea-${compId}" value="${testText}"></textarea>
<textarea aria-describedby="null null" aria-invalid="false" id="admiralty-textarea-${compId}" value="${testText}"></textarea>
<admiralty-input-invalid style="visibility: hidden;"></admiralty-input-invalid>
</div>
</admiralty-textarea>
Expand All @@ -60,7 +61,7 @@ describe('admiralty-textarea', () => {
<admiralty-textarea label="Description" disabled="true">
<div class="text-area-container">
<admiralty-label disabled="" for="admiralty-textarea-${compId}">Description</admiralty-label>
<textarea class="disabled" id="admiralty-textarea-${compId}" value=""></textarea>
<textarea aria-describedby="null null" aria-invalid="false" class="disabled" id="admiralty-textarea-${compId}" value=""></textarea>
<admiralty-input-invalid style="visibility: hidden;"></admiralty-input-invalid>
</div>
</admiralty-textarea>
Expand All @@ -69,6 +70,7 @@ describe('admiralty-textarea', () => {

it('should render invalid state', async () => {
++compId;
++errorId;

const page = await newSpecPage({
components: [TextareaComponent],
Expand All @@ -79,7 +81,7 @@ describe('admiralty-textarea', () => {
<admiralty-textarea label="Description" invalid="true" invalid-message="BAD">
<div class="text-area-container">
<admiralty-label for="admiralty-textarea-${compId}">Description</admiralty-label>
<textarea class="invalid" id="admiralty-textarea-${compId}" value=""></textarea>
<textarea aria-describedby="null admiralty-textarea-error-${errorId}" aria-invalid="true" class="invalid" id="admiralty-textarea-${compId}" value=""></textarea>
<admiralty-input-invalid style="visibility: visible;">
BAD
</admiralty-input-invalid>
Expand All @@ -90,6 +92,7 @@ describe('admiralty-textarea', () => {

it('should not show admiralty-input-invalid when invalid but no message provided', async () => {
++compId;
++errorId;

const page = await newSpecPage({
components: [TextareaComponent],
Expand All @@ -100,7 +103,7 @@ describe('admiralty-textarea', () => {
<admiralty-textarea label="Description" invalid="true" invalidMessage="">
<div class="text-area-container">
<admiralty-label for="admiralty-textarea-${compId}">Description</admiralty-label>
<textarea class="invalid" id="admiralty-textarea-${compId}" value=""></textarea>
<textarea aria-describedby="null admiralty-textarea-error-${errorId}" aria-invalid="true" class="invalid" id="admiralty-textarea-${compId}" value=""></textarea>
<admiralty-input-invalid style="visibility: hidden;"></admiralty-input-invalid>
</div>
</admiralty-textarea>
Expand Down
14 changes: 9 additions & 5 deletions packages/core/src/components/textarea/textarea.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { Component, Host, h, Prop, Event, EventEmitter, Watch } from '@stencil/core';
import { TextAreaChangeEventDetail } from './textarea.interface';

let textareaIds = 0;

@Component({
tag: 'admiralty-textarea',
styleUrl: 'textarea.scss',
scoped: true,
})
export class TextareaComponent {
private inputId = `admiralty-textarea-${textareaIds++}`;

private id = ++nextId;
private nativeTextArea?: HTMLTextAreaElement;
textareaId: string = `admiralty-textarea-${this.id}`;
hintId: string = `admiralty-textarea-hint-${this.id}`;
errorId: string = `admiralty-textarea-error-${this.id}`;

/**
* The label which will be used as a placeholder in the unfilled state, and as a field label in the filled state.
Expand Down Expand Up @@ -99,7 +100,7 @@ export class TextareaComponent {
<Host>
<div class="text-area-container">
{this.label ? (
<admiralty-label for={this.inputId} disabled={this.disabled}>
<admiralty-label for={this.textareaId} disabled={this.disabled}>
{this.label}
</admiralty-label>
) : null}
Expand All @@ -108,15 +109,18 @@ export class TextareaComponent {
ref={textArea => (this.nativeTextArea = textArea)}
class={{ disabled: this.disabled, invalid: this.invalid }}
style={this.width ? { maxWidth: `${this.width}px` } : {}}
id={this.inputId}
id={this.textareaId}
value={value}
maxLength={this.maxLength}
onInput={this.onInput}
onBlur={this.onBlur}
aria-invalid={this.invalid ? 'true' : 'false'}
aria-describedby={(this.hint ? this.hintId : null) + ' ' + (this.invalid ? this.errorId : null)}
></textarea>
<admiralty-input-invalid style={{ visibility: this.invalid && this.invalidMessage ? 'visible' : 'hidden' }}>{this.invalidMessage}</admiralty-input-invalid>
</div>
</Host>
);
}
}
let nextId = 0;