Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 7 additions & 10 deletions app/components/memorable_date_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
},
false,
) %>
<lg-validated-field>
<lg-validated-field error-id="validated-field-error-<%= unique_id %>">
<%= f.simple_fields_for name do |p| %>
<%= p.input(
:month,
Expand All @@ -33,23 +33,22 @@
maxLength: 2,
aria: {
invalid: false,
describedby: "validated-field-error-#{unique_id}",
labelledby: [
"memorable-date-month-label-#{unique_id}",
"memorable-date-month-hint-#{unique_id}",
],
},
value: month,
},
error: { id: "validated-field-error-#{unique_id}" },
error: false,
required: required,
) %>
<% end %>
<span id=<%= "memorable-date-month-hint-#{unique_id}" %> class="display-none">
<%= t('in_person_proofing.form.state_id.date_hint.month') %>
</span>
</lg-validated-field>
<lg-validated-field>
<lg-validated-field error-id="validated-field-error-<%= unique_id %>">
<%= f.simple_fields_for name do |p| %>
<%= p.input(
:day,
Expand All @@ -67,23 +66,22 @@
maxLength: 2,
aria: {
invalid: false,
describedby: "validated-field-error-#{unique_id}",
labelledby: [
"memorable-date-day-label-#{unique_id}",
"memorable-date-day-hint-#{unique_id}",
],
},
value: day,
},
error: { id: "validated-field-error-#{unique_id}" },
error: false,
required: required,
) %>
<% end %>
<span id=<%= "memorable-date-day-hint-#{unique_id}" %> class="display-none">
<%= t('in_person_proofing.form.state_id.date_hint.day') %>
</span>
</lg-validated-field>
<lg-validated-field>
<lg-validated-field error-id="validated-field-error-<%= unique_id %>">
<%= f.simple_fields_for name do |p| %>
<%= p.input(
:year,
Expand All @@ -101,15 +99,14 @@
maxLength: 4,
aria: {
invalid: false,
describedby: "validated-field-error-#{unique_id}",
labelledby: [
"memorable-date-year-label-#{unique_id}",
"memorable-date-year-hint-#{unique_id}",
],
},
value: year,
},
error: { id: "validated-field-error-#{unique_id}" },
error: false,
required: required,
) %>
<% end %>
Expand All @@ -119,4 +116,4 @@
</lg-validated-field>
</div>
<% end -%>
<div class="usa-error-message" id="validated-field-error-<%= unique_id %>" style="display:none;"></div>
<div id="validated-field-error-<%= unique_id %>" class="usa-error-message display-none"></div>
16 changes: 5 additions & 11 deletions app/components/validated_field_component.html.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<lg-validated-field>
<%= content_tag(:'lg-validated-field', 'error-id': error_id) do %>
<%= content_tag(
:script,
error_messages.to_json,
Expand All @@ -18,17 +18,11 @@
class: [*tag_options.dig(:input_html, :class), 'validated-field__input'],
aria: {
invalid: false,
describedby: [
*tag_options.dig(:input_html, :aria, :describedby),
"validated-field-error-#{unique_id}",
"validated-field-hint-#{unique_id}",
],
describedby: aria_describedby_idrefs,
},
},
hint_html: {
id: "validated-field-hint-#{unique_id}",
},
error_html: { id: "validated-field-error-#{unique_id}" },
hint_html: { id: hint_id },
error_html: { id: error_id },
),
) %>
</lg-validated-field>
<% end %>
23 changes: 23 additions & 0 deletions app/components/validated_field_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,31 @@ def error_messages
}.compact
end

def aria_describedby_idrefs
idrefs = [*tag_options.dig(:input_html, :aria, :describedby)]
idrefs << error_id if has_errors?
idrefs << hint_id if has_hint?
idrefs
end

private

def has_errors?
form.object.respond_to?(:errors) && form.object.errors.key?(name)
end

def has_hint?
tag_options.key?(:hint)
end

def error_id
"validated-field-error-#{unique_id}"
end

def hint_id
"validated-field-hint-#{unique_id}"
end

def value_missing_error_message
case input_type
when :boolean
Expand Down
26 changes: 10 additions & 16 deletions app/javascript/packages/memorable-date/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,9 @@ describe('MemorableDateElement', () => {
let submitButton;

function expectErrorToEqual(text: string) {
if (errorMessageElement.style.display !== 'none') {
expect(errorMessageElement.textContent).to.equal(text);
} else {
expect('').to.equal(text);
}
// Improvement idea: Assert that the computed accessible description of the input includes text.
expect(errorMessageElement.textContent).to.equal(text);
expect(errorMessageElement.classList.contains('display-none')).to.equal(!text);
}

beforeEach(() => {
Expand All @@ -72,41 +70,38 @@ describe('MemorableDateElement', () => {
<div id="test-md-extra-text">This is an arbitrary element to click</div>
<lg-memorable-date id="test-memorable-date">
<script id="test-md-error-mappings" type="application/json" class="memorable-date__error-strings"></script>
<lg-validated-field>
<lg-validated-field error-id="test-md-error-message">
<input type="text"
id="test-md-month"
required="required"
class="validated-field__input memorable-date__month"
aria-invalid="false"
aria-describedby="test-md-error-message"
pattern="(1[0-2])|(0?[1-9])"
minlength="1"
maxlength="2" />
</lg-validated-field>
<lg-validated-field>
<lg-validated-field error-id="test-md-error-message">
<input type="text"
id="test-md-day"
required="required"
class="validated-field__input memorable-date__day"
aria-invalid="false"
aria-describedby="test-md-error-message"
pattern="(3[01])|([12][0-9])|(0?[1-9])"
minlength="1"
maxlength="2" />
</lg-validated-field>
<lg-validated-field>
<lg-validated-field error-id="test-md-error-message">
<input type="text"
id="test-md-year"
required="required"
class="validated-field__input memorable-date__year"
aria-invalid="false"
aria-describedby="test-md-error-message"
pattern="\\d{4}"
minlength="4"
maxlength="4" />
</lg-validated-field>
</lg-memorable-date>
<div id="test-md-error-message" class="usa-error-message" style="display:none;"></div>
<div id="test-md-error-message" class="usa-error-message display-none"></div>
<button id="test-md-submit">Submit</button>
</form>
`;
Expand Down Expand Up @@ -233,9 +228,8 @@ describe('MemorableDateElement', () => {
function itHidesValidationErrorsOnTyping() {
it('hides validation errors on typing', async () => {
const expectNoVisibleError = () => {
expect(errorMessageElement).to.satisfy(
(element: HTMLDivElement) => element.style.display === 'none' || !element.textContent,
);
expect(errorMessageElement.classList.contains('display-none')).to.be.true();
expect(errorMessageElement.textContent).to.be.empty();
expect(Array.from(monthInput.classList)).not.to.contain('usa-input--error');
expect(monthInput.getAttribute('aria-invalid')).to.equal('false');
expect(Array.from(dayInput.classList)).not.to.contain('usa-input--error');
Expand All @@ -245,7 +239,7 @@ describe('MemorableDateElement', () => {
};

const expectVisibleError = () => {
expect(errorMessageElement.style.display).not.to.equal('none');
expect(errorMessageElement.classList.contains('display-none')).to.be.false();
expect(errorMessageElement.textContent).not.to.be.empty();
expect(Array.from(monthInput.classList)).to.contain('usa-input--error');
expect(monthInput.getAttribute('aria-invalid')).to.equal('true');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,34 @@
import sinon from 'sinon';
import { getByRole, getByText } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { computeAccessibleDescription } from 'dom-accessibility-api';
import './validated-field-element';

describe('ValidatedFieldElement', () => {
let idCounter = 0;

function createAndConnectElement({ hasInitialError = false, errorInsideField = true } = {}) {
const element = document.createElement('lg-validated-field');
const errorMessageId = ++idCounter;
const errorHtml = hasInitialError
? `<div class="usa-error-message" id="validated-field-error-${errorMessageId}">Invalid value</div>`
: '';
const errorMessageId = `validated-field-error-${++idCounter}`;
element.setAttribute('error-id', errorMessageId);
const errorHtml =
hasInitialError || !errorInsideField
? `<div class="usa-error-message display-none" id="${errorMessageId}">${
hasInitialError ? 'Invalid value' : ''
}</div>`
: '';
element.innerHTML = `
<script type="application/json" class="validated-field__error-strings">
{
"valueMissing": "This field is required"
}
</script>
<div class="validated-field__input-wrapper">
<label id="validated-field-label" class="usa-label">Required Field</label>
<label for="zipcode">ZIP code</label>
<span id="validated-field-hint">Required Field</span>
<input
aria-invalid="false"
aria-describedby="validated-field-label validated-field-error-${errorMessageId}"
aria-describedby="validated-field-hint${hasInitialError ? ` ${errorMessageId}` : ''}"
required="required"
aria-required="true"
class="validated-field__input${hasInitialError ? ' usa-input--error' : ''}"
Expand All @@ -43,6 +49,23 @@ describe('ValidatedFieldElement', () => {
return element;
}

it('does not have an error message by default', () => {
const element = createAndConnectElement();

expect(element.querySelector('.usa-error-message')).to.not.exist();
});

it('does not have an error message while the value is valid', async () => {
const element = createAndConnectElement();

const input = getByRole(element, 'textbox');
await userEvent.type(input, '5');

input.closest('form')!.checkValidity();

expect(element.querySelector('.usa-error-message')).to.not.exist();
});

it('shows error state and focuses on form validation', () => {
const element = createAndConnectElement();

Expand All @@ -54,9 +77,10 @@ describe('ValidatedFieldElement', () => {
expect(input.classList.contains('usa-input--error')).to.be.true();
expect(input.getAttribute('aria-invalid')).to.equal('true');
expect(document.activeElement).to.equal(input);
const message = getByText(element, 'This field is required');
expect(message).to.be.ok();
expect(message.id).to.equal('validated-field-error-1');
expect(form.querySelector('.usa-error-message:not(.display-none)')).to.exist();
expect(computeAccessibleDescription(document.activeElement!)).to.equal(
'Required Field This field is required',
);
});

it('shows custom validity as message content', () => {
Expand Down Expand Up @@ -84,7 +108,8 @@ describe('ValidatedFieldElement', () => {

expect(input.classList.contains('usa-input--error')).to.be.false();
expect(input.getAttribute('aria-invalid')).to.equal('false');
expect(getByText(element, 'This field is required').style.display).to.equal('none');
expect(form.querySelector('.usa-error-message:not(.display-none)')).not.to.exist();
expect(computeAccessibleDescription(document.activeElement!)).to.equal('Required Field');
});

it('focuses the first element with an error', () => {
Expand Down Expand Up @@ -114,34 +139,57 @@ describe('ValidatedFieldElement', () => {
expect(input.classList.contains('usa-input--error')).to.be.false();
expect(input.getAttribute('aria-invalid')).to.equal('false');
expect(() => getByText(element, 'Invalid value')).to.throw();
expect(form.querySelector('.usa-error-message:not(.display-none)')).not.to.exist();
});
});

context('with error message element pre-rendered in the DOM', () => {
it('reuses the error message element from inside the tag', () => {
const element = createAndConnectElement({ hasInitialError: true, errorInsideField: true });
const input = getByRole(element, 'textbox');

expect(() => getByText(element, 'Invalid value')).not.to.throw();
expect(() => getByText(element, 'This field is required')).to.throw();
expect(computeAccessibleDescription(input)).to.equal('Required Field Invalid value');

const form = element.parentNode as HTMLFormElement;
form.checkValidity();

expect(computeAccessibleDescription(input)).to.equal('Required Field This field is required');
expect(() => getByText(element, 'Invalid value')).to.throw();
expect(() => getByText(element, 'This field is required')).not.to.throw();
expect(form.querySelector('.usa-error-message:not(.display-none)')).to.exist();
});

it('reuses the error message element from outside the tag', () => {
const element = createAndConnectElement({ hasInitialError: true, errorInsideField: false });
const input = getByRole(element, 'textbox');
const form = element.parentNode as HTMLFormElement;

expect(() => getByText(form, 'Invalid value')).not.to.throw();
expect(() => getByText(form, 'This field is required')).to.throw();
expect(computeAccessibleDescription(input)).to.equal('Required Field Invalid value');

form.checkValidity();

expect(computeAccessibleDescription(input)).to.equal('Required Field This field is required');
expect(() => getByText(form, 'Invalid value')).to.throw();
expect(() => getByText(form, 'This field is required')).not.to.throw();
expect(form.querySelector('.usa-error-message:not(.display-none)')).to.exist();
});

it('links input to external error message element when input is invalid', () => {
const element = createAndConnectElement({ hasInitialError: false, errorInsideField: false });
const form = element.parentNode as HTMLFormElement;

form.checkValidity();

const input = getByRole(element, 'textbox');
expect(computeAccessibleDescription(input)).to.equal('Required Field This field is required');
expect(form.querySelector('.usa-error-message:not(.display-none)')).to.exist();
});

it('clears error message when field becomes valid', async () => {
const element = createAndConnectElement({ hasInitialError: true });
const input = getByRole(element, 'textbox');
await userEvent.type(input, '5');

expect(computeAccessibleDescription(input)).to.equal('Required Field');
expect(element.querySelector('.usa-error-message:not(.display-none)')).not.to.exist();
});
});

Expand Down
Loading