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
31 changes: 26 additions & 5 deletions dist/pathfora.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@
background-color: #f1f1f1;
color: #888;
font-size: 15px;
/* The live region an inline widget announces its form state through. It has
to stay rendered to stay in the accessibility tree, so it is clipped
rather than hidden with display: none or visibility: hidden. */
/* Load-bearing, not cosmetic: a revealed state moves focus to the container
so a screen reader reads the state out, and when script moves focus while
the element losing it matches :focus-visible - a keyboard user pressing
Enter on Confirm - the element receiving it matches too. Without this rule
Chrome draws outline: auto around the whole full-viewport gate or modal
container. Do not remove. */
/* NOTE no transition here: nothing about the state swap changes opacity, and
declaring one on the widget root overrides .slide-transition(), which
costs slideouts and bars their slide-out animation when the state's delay
closes the widget a few seconds later. */
}
.pf-widget .pf-widget-body {
color: #888;
Expand Down Expand Up @@ -220,8 +233,19 @@
.pf-widget .error-state {
display: none;
}
.pf-widget.success {
transition: opacity 0.3s;
.pf-widget .pf-widget-announcement {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.pf-widget .pf-widget-container:focus {
outline: none;
}
.pf-widget.success .pf-widget-headline,
.pf-widget.success .pf-widget-message,
Expand All @@ -236,9 +260,6 @@
.pf-widget.success .success-state form {
display: block;
}
.pf-widget.error {
transition: opacity 0.3s;
}
.pf-widget.error .pf-widget-headline,
.pf-widget.error .pf-widget-message,
.pf-widget.error form {
Expand Down
334 changes: 297 additions & 37 deletions dist/pathfora.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/pathfora.min.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/pathfora.min.js

Large diffs are not rendered by default.

33 changes: 29 additions & 4 deletions src/less/widgets/widgets-general.less
Original file line number Diff line number Diff line change
Expand Up @@ -240,9 +240,36 @@
display: none;
}

&.success {
transition: opacity 0.3s;
/* The live region an inline widget announces its form state through. It has
to stay rendered to stay in the accessibility tree, so it is clipped
rather than hidden with display: none or visibility: hidden. */
.pf-widget-announcement {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

/* Load-bearing, not cosmetic: a revealed state moves focus to the container
so a screen reader reads the state out, and when script moves focus while
the element losing it matches :focus-visible - a keyboard user pressing
Enter on Confirm - the element receiving it matches too. Without this rule
Chrome draws outline: auto around the whole full-viewport gate or modal
container. Do not remove. */
.pf-widget-container:focus {
Comment thread
ashyablok-cs marked this conversation as resolved.
outline: none;
}

/* NOTE no transition here: nothing about the state swap changes opacity, and
declaring one on the widget root overrides .slide-transition(), which
costs slideouts and bars their slide-out animation when the state's delay
closes the widget a few seconds later. */
&.success {
.pf-widget-headline,
.pf-widget-message,
form {
Expand All @@ -261,8 +288,6 @@
}

&.error {
transition: opacity 0.3s;

.pf-widget-headline,
.pf-widget-message,
form {
Expand Down
93 changes: 93 additions & 0 deletions src/rollup/form/announce-form-state.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/** @module pathfora/form/announce-form-state */

// dom
import document from '../dom/document';

// widgets
import describeWidgetContainer from '../widgets/describe-widget-container';

/**
* Read the text out of a state element's headline or message.
*
* @params {object} state
* @params {string} selector
* @returns {string}
*/
function stateText(state, selector) {
var el = state.querySelector(selector);

return el ? el.textContent || el.innerText || '' : '';
}

/**
* Make a revealed form success or error state perceivable to assistive
* technology.
*
* The state is revealed by CSS alone, which is silent, and the same rules hide
* the button the user just activated - so a dialog is renamed after its new
* contents and handed focus, which is what gets it read out and keeps a
* keyboard user from being dropped back to the top of the page. An inline
* widget sits in the page's own flow and should not steal focus, so its text
* is copied into the live region built alongside the states instead.
*
* @exports announceFormState
* @params {object} widget
* @params {string} name
*/
export default function announceFormState(widget, name) {
var state = widget.querySelector('.' + name + '-state'),
container = widget.querySelector('.pf-widget-container');

if (!state || !container) {
return;
}

if (container.getAttribute('role') !== 'dialog') {
var region = widget.querySelector('.pf-widget-announcement');

if (!region) {
return;
}

// NOTE the headline and message only, never the state's own buttons: the
// implicit aria-atomic on role=status means everything in here is read as
// one message, and "Thank You. We have received your submission." should
// not end in "Confirm Cancel"
var announcement = [
stateText(state, '.pf-widget-headline'),
stateText(state, '.pf-widget-message'),
]
.filter(function (text) {
return text.length > 0;
})
.join('. ');

if (!announcement.length) {
return;
}

// NOTE written a tick late, after the class that reveals the state has
// been applied and styles have settled: Safari and VoiceOver are the
// least forgiving about text that arrives in the same tick as the change
// around it
setTimeout(function () {
while (region.firstChild) {
region.removeChild(region.firstChild);
}

region.appendChild(document.createTextNode(announcement));
}, 0);

return;
}

describeWidgetContainer(
container,
state.querySelector('.pf-widget-headline'),
state.querySelector('.pf-widget-message'),
widget.id + '-' + name,
);

container.setAttribute('tabindex', '-1');
container.focus();
}
30 changes: 30 additions & 0 deletions src/rollup/form/construct-state-live-region.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/** @module pathfora/form/construct-state-live-region */

// dom
import document from '../dom/document';

/**
* Build the empty live region an inline widget announces its form state
* through.
*
* A live region is only reliably announced when it is already rendered and
* empty at the moment its text arrives - a region that enters the
* accessibility tree with its text already inside is the classic case screen
* readers skip. So the region is built with the state elements, well before
* either state is revealed, and announceFormState writes into it.
*
* It is visually hidden rather than display: none, which would take it out of
* the accessibility tree along with its announcement, and it holds only the
* state's headline and message - role=status carries an implicit aria-atomic,
* so anything else in here would be read out with them.
*
* @exports constructStateLiveRegion
*/
export default function constructStateLiveRegion() {
var region = document.createElement('div');

region.className = 'pf-widget-announcement';
region.setAttribute('role', 'status');

return region;
}
5 changes: 5 additions & 0 deletions src/rollup/form/handle-form-states.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import addClass from '../utils/class/add-class';
// widgets
import closeWidget from '../widgets/close-widget';

// form
import announceFormState from './announce-form-state';

/**
* Handles showing the success or error state of a form.
*
Expand All @@ -20,9 +23,11 @@ export default function handleFormStates (successful, widget, config) {

if (successful) {
addClass(widget, 'success');
announceFormState(widget, 'success');
delay = config.formStates.success && typeof config.formStates.success.delay !== 'undefined' ? config.formStates.success.delay * 1000 : 3000;
} else {
addClass(widget, 'error');
announceFormState(widget, 'error');
delay = config.formStates.error && typeof config.formStates.error.delay !== 'undefined' ? config.formStates.error.delay * 1000 : 3000;
}

Expand Down
10 changes: 10 additions & 0 deletions src/rollup/widgets/construct-widget-layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import removeClass from '../utils/class/remove-class';
// form
import buildWidgetForm from '../form/build-widget-form';
import constructFormState from '../form/construct-form-state';
import constructStateLiveRegion from '../form/construct-state-live-region';

// widgets
import formStateActions from './actions/form-state-actions';
Expand Down Expand Up @@ -96,6 +97,15 @@ export default function constructWidgetLayout(widget, config) {
formStateActions(config, widget, 'error');
}

// NOTE an inline widget sits in the page's own flow and must not
// steal focus, so it announces its state through a live region
// instead - which has to be in the document before the state is
// revealed to be announced at all. Every other layout is a dialog
// that gets renamed and focused instead, and needs no region.
if (config.layout === 'inline') {
widgetContent.appendChild(constructStateLiveRegion());
}

break;
}
break;
Expand Down
5 changes: 5 additions & 0 deletions src/rollup/widgets/create-widget-html.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import constructWidgetActions from './actions/construct-widget-actions';
import setupWidgetContentUnit from './recommendations/setup-widget-content-unit';
import setWidgetClassname from './set-widget-classname';
import constructWidgetLayout from './construct-widget-layout';
import setupWidgetAria from './setup-widget-aria';
import setupWidgetColors from './colors/setup-widget-colors';

/**
Expand All @@ -32,6 +33,10 @@ export default function createWidgetHtml (config) {
throw new Error('Could not get pathfora template based on type and layout.');
}

// NOTE must run against the untouched template, before the form state
// elements duplicate the headline and message classes
setupWidgetAria(widget, config);

setupWidgetPosition(widget, config);
constructWidgetActions(widget, config);
setupWidgetContentUnit(widget, config);
Expand Down
53 changes: 53 additions & 0 deletions src/rollup/widgets/describe-widget-container.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/** @module pathfora/widgets/describe-widget-container */

/**
* Point a widget container's aria-labelledby/aria-describedby at the headline
* and message elements it should be named and described by, giving each one an
* id to be referenced by.
*
* Ids are namespaced under the widget id, which pathfora already guarantees to
* be unique, so several widgets open at once cannot collide. A widget holding
* more than one headline and message - a form and the success or error state
* that replaces it - passes a distinct namespace per set.
*
* Callers pass null for an element that holds no text: a reference to an empty
* or absent element leaves the container unnamed just as surely as no reference
* at all, so the reference is cleared instead.
*
* @exports describeWidgetContainer
* @params {object} container
* @params {object} headline
* @params {object} message
* @params {string} namespace
*/
export default function describeWidgetContainer(
container,
headline,
message,
namespace,
) {
if (headline) {
headline.id = namespace + '-pf-widget-headline';
Comment thread
ashyablok-cs marked this conversation as resolved.
}

if (message) {
message.id = namespace + '-pf-widget-message';
}

// NOTE bar layouts have no headline element at all, so the message is the
// only text available to name the container with
var name = headline || message,
description = headline ? message : null;

if (name) {
container.setAttribute('aria-labelledby', name.id);
} else {
container.removeAttribute('aria-labelledby');
}

if (description) {
container.setAttribute('aria-describedby', description.id);
} else {
container.removeAttribute('aria-describedby');
}
}
27 changes: 27 additions & 0 deletions src/rollup/widgets/setup-widget-aria.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/** @module pathfora/widgets/setup-widget-aria */

// widgets
import describeWidgetContainer from './describe-widget-container';

/**
* Give the widget container an accessible name and description by pointing
* aria-labelledby/aria-describedby at the widget's own headline and message.
*
* @exports setupWidgetAria
* @params {object} widget
* @params {object} config
*/
export default function setupWidgetAria(widget, config) {
var container = widget.querySelector('.pf-widget-container');

if (!container) {
return;
}

describeWidgetContainer(
container,
config.headline ? widget.querySelector('.pf-widget-headline') : null,
config.msg ? widget.querySelector('.pf-widget-message') : null,
config.id,
);
}
Loading
Loading