Skip to content
Open
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
14 changes: 9 additions & 5 deletions dist/pathfora.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@
background-color: #f1f1f1;
color: #888;
font-size: 15px;
/* A revealed state renames the dialog after its own text and takes focus, so
that a screen reader reads it out. The container is only ever focused
programmatically - never by tabbing - so it needs no focus ring. */
/* 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 +227,8 @@
.pf-widget .error-state {
display: none;
}
.pf-widget.success {
transition: opacity 0.3s;
.pf-widget .pf-widget-container:focus {
outline: none;
}
.pf-widget.success .pf-widget-headline,
.pf-widget.success .pf-widget-message,
Expand All @@ -236,9 +243,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
229 changes: 193 additions & 36 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.

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

&.success {
transition: opacity 0.3s;
/* A revealed state renames the dialog after its own text and takes focus, so
that a screen reader reads it out. The container is only ever focused
programmatically - never by tabbing - so it needs no focus ring. */
.pf-widget-container:focus {
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 +270,6 @@
}

&.error {
transition: opacity 0.3s;

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

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

/**
* 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 state
* is announced politely as a live region 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') {
// NOTE role=status carries an implicit aria-atomic, so the headline and
// message are read as a single message
state.setAttribute('role', 'status');
return;
}

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

container.setAttribute('tabindex', '-1');
container.focus();
}
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
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';
}

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,
);
}
75 changes: 51 additions & 24 deletions src/rollup/widgets/show-widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,29 +63,47 @@ export default function showWidget(w) {
document.body.appendChild(node);

if (widget.layout === 'modal' || widget.layout === 'gate') {
// ensure that we set focus the the modal for accessibility reasons
var focusable = node.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);

if (focusable.length) {
widget.listeners.tabindex = {
type: 'keydown',
target: document,
fn: function (ev) {
// for modal and sitegate widgets we need to limit tab cycle focus to the widget
if (ev.keyCode === 9) {
if (!node.contains(event.target)) {
ev.preventDefault();
focusable[0].focus();
} else if (ev.target === focusable[focusable.length - 1]) {
ev.preventDefault();
focusable[0].focus();
}
}
},
};
}
// NOTE the set is recomputed on every tab rather than captured here:
// form widgets swap their form out for a success or error state, so a
// set captured at open time would send focus to elements that are
// display: none by the time the user tabs. getClientRects is the check
// rather than offsetParent, which is null for the position: fixed
// widget content of a modal
var focusableInWidget = function () {
return Array.prototype.filter.call(
node.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
),
function (el) {
return el.getClientRects().length > 0;
}
);
};

widget.listeners.tabindex = {
type: 'keydown',
target: document,
fn: function (ev) {
// for modal and sitegate widgets we need to limit tab cycle focus to the widget
if (ev.keyCode !== 9) {
return;
}

var focusable = focusableInWidget();

if (!focusable.length) {
return;
}

if (
!node.contains(ev.target) ||
ev.target === focusable[focusable.length - 1]
) {
ev.preventDefault();
focusable[0].focus();
}
},
};
}
} else {
// support legacy inline layout used position as selector.
Expand Down Expand Up @@ -164,7 +182,16 @@ export default function showWidget(w) {
if (w.displayConditions && w.displayConditions.showDelay) {
widgetTracker.delayedWidgets[w.id] = setTimeout(function () {
openWidget(w);
document.querySelector('.pf-widget-ok').focus();

// NOTE scoped to this widget, and optional: with several widgets open an
// unscoped lookup focuses whichever one comes first in the document, and
// a widget configured with okShow: false has no such button at all
var node = document.getElementById(w.id),
ok = node && node.querySelector('.pf-widget-ok');

if (ok) {
ok.focus();
}
}, w.displayConditions.showDelay * 1000);
} else {
openWidget(w);
Expand Down
6 changes: 3 additions & 3 deletions src/templates/form/gate.html
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
<div class="pf-widget-container" role="dialog" aria-labelledby="pf-widget-headline" aria-describedby="pf-widget-message" aria-modal="true">
<div class="pf-widget-container" role="dialog" aria-modal="true">
<div class="pf-va-middle">
<div class="pf-widget-content">
<div class="pf-widget-text">
<h2 class="pf-widget-headline" id="pf-widget-headline"></h2>
<h2 class="pf-widget-headline"></h2>
<div class="pf-widget-body">
<div class="pf-va-middle">
<p class="pf-widget-message" id="pf-widget-message"></p>
<p class="pf-widget-message"></p>
<form>
<input name="username" type="text" aria-label="Name">
<input name="email" type="email" aria-label="Email">
Expand Down
6 changes: 3 additions & 3 deletions src/templates/form/modal.html
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
<div class="pf-widget-container" role="dialog" aria-labelledby="pf-widget-headline" aria-describedby="pf-widget-message" aria-modal="true">
<div class="pf-widget-container" role="dialog" aria-modal="true">
<div class="pf-va-middle">
<div class="pf-widget-content">
<button type="button" class="pf-widget-close" aria-label="Close">&times;</button>
<div class="pf-widget-text">
<h2 class="pf-widget-headline" id="pf-widget-headline"></h2>
<h2 class="pf-widget-headline"></h2>
<div class="pf-widget-body">
<div class="pf-va-middle">
<p class="pf-widget-message" id="pf-widget-message"></p>
<p class="pf-widget-message"></p>
<form>
<input name="username" type="text" aria-label="Name">
<input name="email" type="email" aria-label="Email">
Expand Down
38 changes: 20 additions & 18 deletions src/templates/form/slideout.html
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
<button type="button" class="pf-widget-close" aria-label="Close">&times;</button>
<div class="pf-widget-body"></div>
<div class="pf-widget-content">
<h2 class="pf-widget-headline"></h2>
<p class="pf-widget-message"></p>
<form>
<input name="username" type="text" aria-label="Name">
<input name="email" type="email" aria-label="Email">
<input class="pf-field-half-width" name="title" type="text" aria-label="Title">
<input class="pf-field-half-width" name="company" type="text" aria-label="Company">
<input class="pf-field-half-width" name="phone" type="text" aria-label="Phone">
{{country}}
<input class="pf-field-half-width" name="referralEmail" type="text" aria-label="Referral Email">
<textarea name="message" rows="5" aria-label="Message"></textarea>
<button type="submit" class="pf-widget-btn pf-widget-ok">Confirm</button>
<button type="button" class="pf-widget-btn pf-widget-cancel">Cancel</button>
</form>
<div class="pf-widget-footer"></div>
<div class="pf-widget-container" role="dialog">
<button type="button" class="pf-widget-close" aria-label="Close">&times;</button>
<div class="pf-widget-body"></div>
<div class="pf-widget-content">
<h2 class="pf-widget-headline"></h2>
<p class="pf-widget-message"></p>
<form>
<input name="username" type="text" aria-label="Name">
<input name="email" type="email" aria-label="Email">
<input class="pf-field-half-width" name="title" type="text" aria-label="Title">
<input class="pf-field-half-width" name="company" type="text" aria-label="Company">
<input class="pf-field-half-width" name="phone" type="text" aria-label="Phone">
{{country}}
<input class="pf-field-half-width" name="referralEmail" type="text" aria-label="Referral Email">
<textarea name="message" rows="5" aria-label="Message"></textarea>
<button type="submit" class="pf-widget-btn pf-widget-ok">Confirm</button>
<button type="button" class="pf-widget-btn pf-widget-cancel">Cancel</button>
</form>
<div class="pf-widget-footer"></div>
</div>
</div>
2 changes: 1 addition & 1 deletion src/templates/message/bar.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div class="pf-widget-container" role="dialog" aria-labelledby="pf-widget-headline" aria-describedby="pf-widget-message">
<div class="pf-widget-container" role="dialog">
<div class="pf-widget-body"></div>
<button type="button" class="pf-widget-close" aria-label="Close">&times;</button>
<div class="pf-bar-content">
Expand Down
Loading
Loading