diff --git a/src/Draggable/Draggable.js b/src/Draggable/Draggable.js index 84385bf9..cfc1b315 100644 --- a/src/Draggable/Draggable.js +++ b/src/Draggable/Draggable.js @@ -1,6 +1,6 @@ import {closest} from 'shared/utils'; -import {Accessibility, Mirror} from './Plugins'; +import {Accessibility, Mirror, Announcement} from './Plugins'; import { MouseSensor, @@ -36,6 +36,17 @@ 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, @@ -43,16 +54,6 @@ const defaults = { 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', - }, }; /** @@ -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({ @@ -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]; } /** diff --git a/src/Draggable/Plugins/Announcement/Announcement.js b/src/Draggable/Plugins/Announcement/Announcement.js new file mode 100644 index 00000000..923adf57 --- /dev/null +++ b/src/Draggable/Plugins/Announcement/Announcement.js @@ -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; +} diff --git a/src/Draggable/Plugins/Announcement/README.md b/src/Draggable/Plugins/Announcement/README.md new file mode 100644 index 00000000..f72bb837 --- /dev/null +++ b/src/Draggable/Plugins/Announcement/README.md @@ -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, +}); +``` diff --git a/src/Draggable/Plugins/Announcement/index.js b/src/Draggable/Plugins/Announcement/index.js new file mode 100644 index 00000000..5e050bd4 --- /dev/null +++ b/src/Draggable/Plugins/Announcement/index.js @@ -0,0 +1,4 @@ +import Announcement, {defaultOptions} from './Announcement'; + +export default Announcement; +export {defaultOptions}; diff --git a/src/Draggable/Plugins/Mirror/index.js b/src/Draggable/Plugins/Mirror/index.js index e43bd7fd..421dc823 100644 --- a/src/Draggable/Plugins/Mirror/index.js +++ b/src/Draggable/Plugins/Mirror/index.js @@ -1,6 +1,4 @@ import Mirror, {defaultOptions} from './Mirror'; export default Mirror; -export { - defaultOptions as defaultMirrorOption, -}; +export {defaultOptions}; diff --git a/src/Draggable/Plugins/README.md b/src/Draggable/Plugins/README.md index 3e6c495c..70ccf7c4 100644 --- a/src/Draggable/Plugins/README.md +++ b/src/Draggable/Plugins/README.md @@ -4,3 +4,4 @@ These plugins are included by draggable by default - (Accessibility)[Accessibility] - (Mirror)[Mirror] +- (Announcement)[Announcement] diff --git a/src/Draggable/Plugins/index.js b/src/Draggable/Plugins/index.js index f886d4b6..fdfc3979 100644 --- a/src/Draggable/Plugins/index.js +++ b/src/Draggable/Plugins/index.js @@ -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'; diff --git a/src/Droppable/Droppable.js b/src/Droppable/Droppable.js index e73df7dd..bc20891a 100644 --- a/src/Droppable/Droppable.js +++ b/src/Droppable/Droppable.js @@ -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', }; @@ -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]; } /**