Skip to content

Commit

Permalink
Announce plugin for screen readers
Browse files Browse the repository at this point in the history
  • Loading branch information
tsov committed Dec 7, 2017
1 parent bcbead4 commit a703bc1
Show file tree
Hide file tree
Showing 8 changed files with 270 additions and 24 deletions.
27 changes: 14 additions & 13 deletions src/Draggable/Draggable.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {closest} from 'shared/utils';

import {Accessibility, Mirror} from './Plugins';
import {Accessibility, Mirror, Announcement} from './Plugins';

import {
MouseSensor,
Expand Down Expand Up @@ -36,23 +36,24 @@ const onDragStop = Symbol('onDragStop');
const onDragPressure = Symbol('onDragPressure');
const getAppendableContainer = Symbol('getAppendableContainer');

const defaultClasses = {
'container:dragging': 'draggable-container--is-dragging',
'source:dragging': 'draggable-source--is-dragging',
'source:placed': 'draggable-source--placed',
'container:placed': 'draggable-container--placed',
'body:dragging': 'draggable--is-dragging',
'draggable:over': 'draggable--over',
'container:over': 'draggable-container--over',
mirror: 'draggable-mirror',
};

const defaults = {
draggable: '.draggable-source',
handle: null,
delay: 100,
placedTimeout: 800,
plugins: [],
sensors: [],
classes: {
'container:dragging': 'draggable-container--is-dragging',
'source:dragging': 'draggable-source--is-dragging',
'source:placed': 'draggable-source--placed',
'container:placed': 'draggable-container--placed',
'body:dragging': 'draggable--is-dragging',
'draggable:over': 'draggable--over',
'container:over': 'draggable-container--over',
mirror: 'draggable-mirror',
},
};

/**
Expand Down Expand Up @@ -117,7 +118,7 @@ export default class Draggable {
document.addEventListener('drag:stop', this[onDragStop], true);
document.addEventListener('drag:pressure', this[onDragPressure], true);

this.addPlugin(...[Mirror, Accessibility, ...this.options.plugins]);
this.addPlugin(...[Mirror, Accessibility, Announcement, ...this.options.plugins]);
this.addSensor(...[MouseSensor, TouchSensor, ...this.options.sensors]);

const draggableInitializedEvent = new DraggableInitializedEvent({
Expand Down Expand Up @@ -279,7 +280,7 @@ export default class Draggable {
* @return {String|null}
*/
getClassNameFor(name) {
return this.options.classes[name] || defaults.classes[name];
return (this.options.classes && this.options.classes[name]) || defaultClasses[name];
}

/**
Expand Down
162 changes: 162 additions & 0 deletions src/Draggable/Plugins/Announcement/Announcement.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
const onInitialize = Symbol('onInitialize');
const onDestroy = Symbol('onDestroy');
const announceEvent = Symbol('announceEvent');
const announceMessage = Symbol('announceMessage');

const ARIA_RELEVANT = 'aria-relevant';
const ARIA_ATOMIC = 'aria-atomic';
const ARIA_LIVE = 'aria-live';
const ROLE = 'role';

export const defaultOptions = {
expire: 7000,
'drag:start': 'Picked up draggable element',
'drag:stop': 'Dropped draggable element',
};

/**
* Announcement plugin
* @class Announcement
* @module Announcement
*/
export default class Announcement {

/**
* Announcement constructor.
* @constructs Announcement
* @param {Draggable} draggable - Draggable instance
*/
constructor(draggable) {

/**
* Draggable instance
* @property draggable
* @type {Draggable}
*/
this.draggable = draggable;

/**
* Plugin options
* @property options
* @type {Object}
*/
this.options = {
...defaultOptions,
...this.getOptions(),
};

/**
* Original draggable trigger method. Hack until we have onAll or on('all')
* @property originalTriggerMethod
* @type {Function}
*/
this.originalTriggerMethod = this.draggable.trigger;

/**
* Live region element
* @property liveRegion
* @type {HTMLElement}
*/
this.liveRegion = createRegion();

this[onInitialize] = this[onInitialize].bind(this);
this[onDestroy] = this[onDestroy].bind(this);
}

/**
* Attaches listeners to draggable
*/
attach() {
this.draggable
.on('draggable:initialize', this[onInitialize]);
}

/**
* Detaches listeners from draggable
*/
detach() {
this.draggable
.off('draggable:destroy', this[onDestroy]);
}

/**
* Returns passed in options
*/
getOptions() {
return this.draggable.options.announcements || {};
}

/**
* Announces event
* @private
* @param {AbstractEvent} event
*/
[announceEvent](event) {
const message = this.options[event.type];

if (message && (typeof message === 'string')) {
this[announceMessage](message);
}

if (message && (typeof message === 'function')) {
this[announceMessage](message(event));
}
}

/**
* Announces message to screen reader
* @private
* @param {String} message
*/
[announceMessage](message) {
const element = document.createElement('div');
element.innerHTML = message;
this.liveRegion.appendChild(element);
return setTimeout(() => {
this.liveRegion.removeChild(element);
}, this.options.expire);
}

/**
* Initialize hander
* @private
*/
[onInitialize]() {
this.draggable.trigger = (event) => {
this[announceEvent](event);
this.originalTriggerMethod.call(this.draggable, event);
};

document.body.appendChild(this.liveRegion);
}

/**
* Destroy hander
* @private
*/
[onDestroy]() {
this.draggable.trigger = this.originalTriggerMethod;
document.body.removeChild(this.liveRegion);
}
}

/**
* Creates region element
* @return {HTMLElement}
*/
function createRegion() {
const element = document.createElement('div');

element.setAttribute(ARIA_RELEVANT, 'additions');
element.setAttribute(ARIA_ATOMIC, 'false');
element.setAttribute(ARIA_LIVE, 'assertive');
element.setAttribute(ROLE, 'status');

element.style.position = 'fixed';
element.style.width = '1px';
element.style.height = '1px';
element.style.top = '-1px';
element.style.overflow = 'hidden';

return element;
}
75 changes: 75 additions & 0 deletions src/Draggable/Plugins/Announcement/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
## Announcement

The Announcement plugin listens to _all_ draggable events and allows you to define announcements via options for events
that are read back through a screen reader.

### API

**`new Announcement(draggable: Draggable): Announcement`**
To create an announcement plugin instance.

### Options

**`expire {Number}`**
How long messages should stay inside the live region (in milliseconds). Default: `7000`

**`'drag:start' {String|Function:String}`**
Define an announcement on `drag:start`. Default: `Picked up draggable element`

**`'drag:stop' {String|Function:String}`**
Define an announcement on `drag:stop`. Default: `Dropped draggable element`

**`'sortable:sorted' {String|Function:String}`**
Define an announcement on `sortable:sorted`. No default

**`'swappable:swapped' {String|Function:String}`**
Define an announcement on `swappable:swapped`. No default

**`'droppable:dropped' {String|Function:String}`**
Define an announcement on `droppable:dropped`. No default

_And any other events you can think of..._

### Examples

#### Static messages

```js
import {Sortable} from '@shopify/draggable';

const announcements = {
'drag:start': 'Draggable element picked up',
'drag:stop': 'Draggable element dropped',
'sortable:stopped': 'Draggable elements swapped',
}

const sortable = new Sortable(document.querySelectorAll('ul'), {
draggable: 'li',
announcements,
});
```

#### Dynamic messages

```js
import {Sortable} from '@shopify/draggable';

const announcements = {
'drag:start': (dragEvent) => {
return `Picked up ${dragEvent.source.getAttribute('data-name')}`;
},

'drag:stop': (dragEvent) => {
return `Dropped ${dragEvent.source.getAttribute('data-name')}`
},

'sortable:sorted': (sortableEvent) => {
return `Sorted ${sortableEvent.dragEvent.source.getAttribute('data-name')} with ${sortableEvent.dragEvent.over.getAttribute('data-name')}`;
},
}

const sortable = new Sortable(document.querySelectorAll('ul'), {
draggable: 'li',
announcements,
});
```
4 changes: 4 additions & 0 deletions src/Draggable/Plugins/Announcement/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import Announcement, {defaultOptions} from './Announcement';

export default Announcement;
export {defaultOptions};
4 changes: 1 addition & 3 deletions src/Draggable/Plugins/Mirror/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import Mirror, {defaultOptions} from './Mirror';

export default Mirror;
export {
defaultOptions as defaultMirrorOption,
};
export {defaultOptions};
1 change: 1 addition & 0 deletions src/Draggable/Plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ These plugins are included by draggable by default

- (Accessibility)[Accessibility]
- (Mirror)[Mirror]
- (Announcement)[Announcement]
17 changes: 11 additions & 6 deletions src/Draggable/Plugins/index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import Mirror, {defaultMirrorOption} from './Mirror';
import Accessibility from './Accessibility';
export {
default as Announcement,
defaultOptions as defaultAnnouncementOptions,
} from './Announcement';

export {
default as Mirror,
defaultOptions as defaultMirrorOptions,
} from './Mirror';

export {
Mirror,
defaultMirrorOption,
Accessibility,
};
default as Accessibility,
} from './Accessibility';
4 changes: 2 additions & 2 deletions src/Droppable/Droppable.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const release = Symbol('release');
const closestDroppable = Symbol('closestDroppable');
const getDroppables = Symbol('getDroppables');

const classes = {
const defaultClasses = {
'droppable:active': 'draggable-droppable--active',
'droppable:occupied': 'draggable-droppable--occupied',
};
Expand Down Expand Up @@ -92,7 +92,7 @@ export default class Droppable extends Draggable {
* @return {String|null}
*/
getClassNameFor(name) {
return super.getClassNameFor(name) || classes[name];
return super.getClassNameFor(name) || defaultClasses[name];
}

/**
Expand Down

0 comments on commit a703bc1

Please sign in to comment.