Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 199 additions & 2 deletions coral-component-numberinput/src/scripts/NumberInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ const NumberInput = Decorator(class extends BaseFormField(BaseComponent(HTMLElem

this.invalid = this.hasAttribute('invalid');
this.disabled = this.hasAttribute('disabled');

this._syncValidationAccessibility();
}

/**
Expand Down Expand Up @@ -374,6 +376,7 @@ const NumberInput = Decorator(class extends BaseFormField(BaseComponent(HTMLElem
set invalid(value) {
super.invalid = value;
this._elements.input.invalid = this._invalid;
this._syncValidationAccessibility();
}

/**
Expand Down Expand Up @@ -819,11 +822,13 @@ const NumberInput = Decorator(class extends BaseFormField(BaseComponent(HTMLElem

const frag = document.createDocumentFragment();

const templateHandleNames = ['presentation', 'input'];
const templateHandleNames = ['presentation', 'input', 'validationMessage'];

// Render main template
// Render main template (validationMessage must be connected — otherwise hint text is not visible and
// aria-describedby / aria-errormessage cannot resolve).
frag.appendChild(this._elements.input);
frag.appendChild(this._elements.presentation);
frag.appendChild(this._elements.validationMessage);

while (this.firstChild) {
const child = this.firstChild;
Expand All @@ -838,6 +843,198 @@ const NumberInput = Decorator(class extends BaseFormField(BaseComponent(HTMLElem
}

this.appendChild(frag);

this._ensureValidationMessageObservers();
this._syncValidationAccessibility();
}

/** @ignore */
disconnectedCallback() {
this._disconnectValidationMessageObservers();
super.disconnectedCallback();
}

/**
Shows validation hints (e.g. from the title attribute or native validationMessage) in a visible region and links
them with aria-describedby so keyboard and screen reader users receive the same information as hover title tooltips.

@ignore
*/
_getValidationMessageId() {
const input = this._elements.input;
if (!input || !input.id) {
return null;
}

return `${input.id}-validation-message`;
}

/** @ignore */
_parseAriaDescribedBy() {
const raw = this._elements.input.getAttribute('aria-describedby');
if (!raw || !raw.trim()) {
return [];
}

return raw.trim().split(/\s+/).filter(Boolean);
}

/** @ignore */
_setAriaDescribedByIds(ids) {
const input = this._elements.input;
if (ids.length) {
input.setAttribute('aria-describedby', ids.join(' '));
} else {
input.removeAttribute('aria-describedby');
}
}

/** @ignore */
_addDescribedById(id) {
const ids = this._parseAriaDescribedBy();
if (ids.indexOf(id) === -1) {
ids.push(id);
}

this._setAriaDescribedByIds(ids);
}

/** @ignore */
_removeDescribedById(id) {
const ids = this._parseAriaDescribedBy().filter((current) => current !== id);
this._setAriaDescribedByIds(ids);
}

/**
Whether the field is in an error state (host or inner), including class-based markers used by Granite validation.

@ignore
*/
_isInErrorState() {
if (this._invalid) {
return true;
}

if (this.classList.contains('is-invalid')) {
return true;
}

const input = this._elements.input;
if (!input) {
return false;
}

if (input.invalid) {
return true;
}

if (input.classList.contains('is-invalid')) {
return true;
}

return false;
}

/** @ignore */
_ensureValidationMessageObservers() {
const input = this._elements.input;
if (!input) {
return;
}

const scheduleSync = () => {
if (!this._disconnected) {
this._syncValidationAccessibility();
}
};

if (!this._validationMessageObserverInput) {
this._validationMessageObserverInput = new MutationObserver(scheduleSync);
this._validationMessageObserverInput.observe(input, {
attributes: true,
attributeFilter: ['title', 'invalid', 'class']
});
}

if (!this._validationMessageObserverHost) {
this._validationMessageObserverHost = new MutationObserver(scheduleSync);
this._validationMessageObserverHost.observe(this, {
attributes: true,
attributeFilter: ['title', 'invalid', 'class']
});
}
}

/** @ignore */
_disconnectValidationMessageObservers() {
if (this._validationMessageObserverInput) {
this._validationMessageObserverInput.disconnect();
this._validationMessageObserverInput = null;
}

if (this._validationMessageObserverHost) {
this._validationMessageObserverHost.disconnect();
this._validationMessageObserverHost = null;
}
}

/** @ignore */
_syncValidationAccessibility() {
const input = this._elements.input;
const panel = this._elements.validationMessage;

if (!input || !panel || !this._rendered) {
return;
}

if (!panel.isConnected) {
return;
}

const messageId = this._getValidationMessageId();
if (!messageId) {
return;
}

const innerTitle = (input.getAttribute('title') || '').trim();
const hostTitle = (this.getAttribute('title') || '').trim();
if (innerTitle) {
this._cachedTitleValidationMessage = innerTitle;
} else if (hostTitle) {
this._cachedTitleValidationMessage = hostTitle;
}

const validationMessage = (input.validationMessage || '').trim();
const text = innerTitle || hostTitle || validationMessage || this._cachedTitleValidationMessage || '';

const shouldShow = Boolean(text) && (this._isInErrorState() || innerTitle || hostTitle || validationMessage);

if (!shouldShow) {
this._cachedTitleValidationMessage = '';
panel.textContent = '';
panel.hidden = true;
panel.removeAttribute('aria-live');
if (panel.getAttribute('id') === messageId) {
panel.removeAttribute('id');
}

this._removeDescribedById(messageId);
input.removeAttribute('aria-errormessage');
return;
}

panel.textContent = text;
panel.id = messageId;
panel.hidden = false;
panel.setAttribute('aria-live', 'polite');
this._addDescribedById(messageId);
input.setAttribute('aria-errormessage', messageId);

if (innerTitle) {
input.removeAttribute('title');
} else if (hostTitle) {
this.removeAttribute('title');
}
}
});

Expand Down
14 changes: 14 additions & 0 deletions coral-component-numberinput/src/styles/index.styl
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,17 @@
._coral-Stepper-input {
-moz-appearance: textfield;
}

// Stepper is a horizontal flex row; force the validation line onto its own row so it stays visible.
._coral-Stepper[role="group"] {
flex-wrap: wrap;
}

._coral-Stepper-validationMessage {
box-sizing: border-box;
display: block;
flex: 1 0 100%;
width: 100%;
max-width: 100%;
margin-top: var(--spectrum-global-dimension-size-50, 4px);
}
1 change: 1 addition & 0 deletions coral-component-numberinput/src/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
</button>
<span role="presentation" handle="liveregion" aria-live="assertive" aria-atomic="true" aria-relevant="additions text" class="u-coral-screenReaderOnly" hidden></span>
</span>
<div class="coral-Form-errorlabel _coral-Stepper-validationMessage" handle="validationMessage" hidden></div>
85 changes: 85 additions & 0 deletions coral-component-numberinput/src/tests/test.NumberInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe('NumberInput', function () {
expect(instance._elements.input).to.exist;
expect(instance._elements.stepUp).to.exist;
expect(instance._elements.stepDown).to.exist;
expect(instance._elements.validationMessage).to.exist;

if (instance._elements.input.type === 'text') {
expect(instance._elements.input.getAttribute('role')).to.equal('spinbutton');
Expand Down Expand Up @@ -124,6 +125,90 @@ describe('NumberInput', function () {
expect(el.getAttribute('invalid')).to.be.null;
});
});

describe('validation message accessibility', function () {
it('should surface title text below the field with aria-describedby when invalid', function () {
const el = helpers.build(new NumberInput());
const input = el._elements.input;
const panel = el._elements.validationMessage;

input.setAttribute('title', 'Use a value between 0 and 10');
el.invalid = true;

expect(panel.isConnected).to.be.true;
expect(el.contains(panel)).to.be.true;
expect(panel.hidden).to.be.false;
expect(panel.textContent).to.equal('Use a value between 0 and 10');
expect(panel.id).to.equal(`${input.id}-validation-message`);
expect(input.getAttribute('title')).to.be.null;
expect(input.getAttribute('aria-describedby').split(/\s+/)).to.include(panel.id);
expect(input.getAttribute('aria-errormessage')).to.equal(panel.id);
});

it('should preserve existing aria-describedby ids when adding the validation message', function () {
const el = helpers.build(new NumberInput());
const input = el._elements.input;
const panel = el._elements.validationMessage;

input.setAttribute('aria-describedby', 'existing-hint');
input.setAttribute('title', 'Correction needed');
el.invalid = true;

const ids = input.getAttribute('aria-describedby').split(/\s+/).filter(Boolean);
expect(ids).to.include('existing-hint');
expect(ids).to.include(panel.id);
});

it('should hide the validation region and drop its describedby id when no longer invalid', function () {
const el = helpers.build(new NumberInput());
const input = el._elements.input;
const panel = el._elements.validationMessage;

input.setAttribute('aria-describedby', 'existing-hint');
input.setAttribute('title', 'Correction needed');
el.invalid = true;

el.invalid = false;

expect(panel.hidden).to.be.true;
expect(panel.textContent).to.equal('');
expect(input.getAttribute('aria-describedby')).to.equal('existing-hint');
expect(input.getAttribute('aria-errormessage')).to.be.null;
});

it('should update when title is set after invalid becomes true', function () {
const el = helpers.build(new NumberInput());
const input = el._elements.input;
const panel = el._elements.validationMessage;

el.invalid = true;
input.setAttribute('title', 'Added later');

return Promise.resolve().then(function () {
expect(panel.hidden).to.be.false;
expect(panel.textContent).to.equal('Added later');
expect(input.getAttribute('aria-describedby').split(/\s+/)).to.include(panel.id);
expect(input.getAttribute('aria-errormessage')).to.equal(panel.id);
});
});

it('should surface host title when host has is-invalid class (Granite-style marker)', function () {
const el = helpers.build(new NumberInput());
const input = el._elements.input;
const panel = el._elements.validationMessage;

el.classList.add('is-invalid');
el.setAttribute('title', 'Error from wrapper');

return Promise.resolve().then(function () {
expect(panel.isConnected).to.be.true;
expect(panel.hidden).to.be.false;
expect(panel.textContent).to.equal('Error from wrapper');
expect(el.getAttribute('title')).to.be.null;
expect(input.getAttribute('aria-describedby').split(/\s+/)).to.include(panel.id);
});
});
});
});

describe('API', function () {
Expand Down
Loading