diff --git a/.azurite/__azurite_db_blob__.json b/.azurite/__azurite_db_blob__.json
deleted file mode 100644
index cc73052f..00000000
--- a/.azurite/__azurite_db_blob__.json
+++ /dev/null
@@ -1 +0,0 @@
-{"filename":"/Users/mick/Development/Open source/components/.azurite/__azurite_db_blob__.json","collections":[{"name":"$SERVICES_COLLECTION$","data":[],"idIndex":null,"binaryIndices":{},"constraints":null,"uniqueNames":["accountName"],"transforms":{},"objType":"$SERVICES_COLLECTION$","dirty":false,"cachedIndex":null,"cachedBinaryIndex":null,"cachedData":null,"adaptiveBinaryIndices":true,"transactional":false,"cloneObjects":false,"cloneMethod":"parse-stringify","asyncListeners":false,"disableMeta":false,"disableChangesApi":true,"disableDeltaChangesApi":true,"autoupdate":false,"serializableIndices":true,"disableFreeze":true,"ttl":null,"maxId":0,"DynamicViews":[],"events":{"insert":[],"update":[],"pre-insert":[],"pre-update":[],"close":[],"flushbuffer":[],"error":[],"delete":[null],"warning":[null]},"changes":[],"dirtyIds":[]},{"name":"$CONTAINERS_COLLECTION$","data":[],"idIndex":null,"binaryIndices":{"accountName":{"name":"accountName","dirty":false,"values":[]},"name":{"name":"name","dirty":false,"values":[]}},"constraints":null,"uniqueNames":[],"transforms":{},"objType":"$CONTAINERS_COLLECTION$","dirty":false,"cachedIndex":null,"cachedBinaryIndex":null,"cachedData":null,"adaptiveBinaryIndices":true,"transactional":false,"cloneObjects":false,"cloneMethod":"parse-stringify","asyncListeners":false,"disableMeta":false,"disableChangesApi":true,"disableDeltaChangesApi":true,"autoupdate":false,"serializableIndices":true,"disableFreeze":true,"ttl":null,"maxId":0,"DynamicViews":[],"events":{"insert":[],"update":[],"pre-insert":[],"pre-update":[],"close":[],"flushbuffer":[],"error":[],"delete":[null],"warning":[null]},"changes":[],"dirtyIds":[]},{"name":"$BLOBS_COLLECTION$","data":[],"idIndex":null,"binaryIndices":{"accountName":{"name":"accountName","dirty":false,"values":[]},"containerName":{"name":"containerName","dirty":false,"values":[]},"name":{"name":"name","dirty":false,"values":[]},"snapshot":{"name":"snapshot","dirty":false,"values":[]}},"constraints":null,"uniqueNames":[],"transforms":{},"objType":"$BLOBS_COLLECTION$","dirty":false,"cachedIndex":null,"cachedBinaryIndex":null,"cachedData":null,"adaptiveBinaryIndices":true,"transactional":false,"cloneObjects":false,"cloneMethod":"parse-stringify","asyncListeners":false,"disableMeta":false,"disableChangesApi":true,"disableDeltaChangesApi":true,"autoupdate":false,"serializableIndices":true,"disableFreeze":true,"ttl":null,"maxId":0,"DynamicViews":[],"events":{"insert":[],"update":[],"pre-insert":[],"pre-update":[],"close":[],"flushbuffer":[],"error":[],"delete":[null],"warning":[null]},"changes":[],"dirtyIds":[]},{"name":"$BLOCKS_COLLECTION$","data":[],"idIndex":null,"binaryIndices":{"accountName":{"name":"accountName","dirty":false,"values":[]},"containerName":{"name":"containerName","dirty":false,"values":[]},"blobName":{"name":"blobName","dirty":false,"values":[]},"name":{"name":"name","dirty":false,"values":[]}},"constraints":null,"uniqueNames":[],"transforms":{},"objType":"$BLOCKS_COLLECTION$","dirty":false,"cachedIndex":null,"cachedBinaryIndex":null,"cachedData":null,"adaptiveBinaryIndices":true,"transactional":false,"cloneObjects":false,"cloneMethod":"parse-stringify","asyncListeners":false,"disableMeta":false,"disableChangesApi":true,"disableDeltaChangesApi":true,"autoupdate":false,"serializableIndices":true,"disableFreeze":true,"ttl":null,"maxId":0,"DynamicViews":[],"events":{"insert":[],"update":[],"pre-insert":[],"pre-update":[],"close":[],"flushbuffer":[],"error":[],"delete":[null],"warning":[null]},"changes":[],"dirtyIds":[]}],"databaseVersion":1.5,"engineVersion":1.5,"autosave":true,"autosaveInterval":5000,"autosaveHandle":null,"throttledSaves":true,"options":{"persistenceMethod":"fs","autosave":true,"autosaveInterval":5000,"serializationMethod":"normal","destructureDelimiter":"$<\n"},"persistenceMethod":"fs","persistenceAdapter":null,"verbose":false,"events":{"init":[null],"loaded":[],"flushChanges":[],"close":[],"changes":[],"warning":[]},"ENV":"NODEJS"}
\ No newline at end of file
diff --git a/.azurite/__azurite_db_blob_extent__.json b/.azurite/__azurite_db_blob_extent__.json
deleted file mode 100644
index f4f40d61..00000000
--- a/.azurite/__azurite_db_blob_extent__.json
+++ /dev/null
@@ -1 +0,0 @@
-{"filename":"/Users/mick/Development/Open source/components/.azurite/__azurite_db_blob_extent__.json","collections":[{"name":"$EXTENTS_COLLECTION$","data":[],"idIndex":null,"binaryIndices":{"id":{"name":"id","dirty":false,"values":[]}},"constraints":null,"uniqueNames":[],"transforms":{},"objType":"$EXTENTS_COLLECTION$","dirty":false,"cachedIndex":null,"cachedBinaryIndex":null,"cachedData":null,"adaptiveBinaryIndices":true,"transactional":false,"cloneObjects":false,"cloneMethod":"parse-stringify","asyncListeners":false,"disableMeta":false,"disableChangesApi":true,"disableDeltaChangesApi":true,"autoupdate":false,"serializableIndices":true,"disableFreeze":true,"ttl":null,"maxId":0,"DynamicViews":[],"events":{"insert":[],"update":[],"pre-insert":[],"pre-update":[],"close":[],"flushbuffer":[],"error":[],"delete":[null],"warning":[null]},"changes":[],"dirtyIds":[]}],"databaseVersion":1.5,"engineVersion":1.5,"autosave":true,"autosaveInterval":5000,"autosaveHandle":null,"throttledSaves":true,"options":{"persistenceMethod":"fs","autosave":true,"autosaveInterval":5000,"serializationMethod":"normal","destructureDelimiter":"$<\n"},"persistenceMethod":"fs","persistenceAdapter":null,"verbose":false,"events":{"init":[null],"loaded":[],"flushChanges":[],"close":[],"changes":[],"warning":[]},"ENV":"NODEJS"}
\ No newline at end of file
diff --git a/packages/gallery/.npmignore b/packages/gallery/.npmignore
new file mode 100644
index 00000000..97d59ca6
--- /dev/null
+++ b/packages/gallery/.npmignore
@@ -0,0 +1,7 @@
+.DS_Store
+*.log
+src
+__tests__
+example
+coverage
+jest.config.js
\ No newline at end of file
diff --git a/packages/gallery/README.md b/packages/gallery/README.md
new file mode 100644
index 00000000..0ad7b5b0
--- /dev/null
+++ b/packages/gallery/README.md
@@ -0,0 +1,184 @@
+# Gallery
+
+Accessible media gallery that can be used inline or in a modal.
+
+---
+
+## Usage
+
+Create the gallery in HTML.
+
+There must be an live region with a `data-gallery-live-region` attribute (an element with `aria-live="polite" aria-atomic="true"` for example) for the accessible announcenments.
+
+Each of the gallery items should have a `data-gallery-item` attribute and an id, which is used to update the document URL and makes slides addressable - you can link to a specific gallery slide using a URL hash matching a slide id. If ids are not added they are programmatically generated.
+
+For navigation, `data-gallery-previous` and `data-gallery-next` attributes on buttons identify them as navigation triggers.
+
+Optionally, a button with `data-gallery-fullscreen` attribute if supporting fullscreen functionality.
+
+Optionally, any buttons with a `data-gallery-navigate` attribute will navigate the gallery to a specific slide, e.g. `data-gallery-navigate="2"`.
+
+```
+
+
+
1 of 5
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+Install the package
+```
+npm i -S @stormid/gallery
+```
+
+Initialise the gallery
+```
+import gallery from '@stormid/gallery';
+
+const [instance] = gallery('js-gallery);
+
+## Options
+
+```
+{
+ startIndex: 0,
+ selector: { //selectors for the gallery elements
+ item: '[data-gallery-item]',
+ fullscreen: '[data-gallery-fullscreen]',
+ liveRegion: '[data-gallery-live-region]',
+ previous: '[data-gallery-previous]',
+ next: '[data-gallery-next]',
+ navigate: '[data-gallery-navigate]'
+ },
+ className: { // classNames used to style different gallery states
+ active: 'is--active',
+ fullscreen: 'is--fullscreen'
+ },
+ manualInitialisation: false, // to prevent automatic initialisation, so it can be initialised via the API
+ updateURL: true, //change URL when item changes
+ announcement(current, total){ //template for the accessible announcement
+ return `${current} of ${total}`;
+ }
+}
+```
+
+## API
+gallery() returns an array of instances. Each instance exposes the interface
+```
+{
+ getState // return the current state Object
+ initialise // to manually initialise if manualInitialisation setting is true
+ goTo // navigate to a slide index (zero indexed)
+ toggleFullScreen // set teh gallery to full screen
+}
+```
+
+## Events
+There are 2 events that an instance of the gallery dispatches:
+- `gallery.initialised` when it's initialised
+- `gallery.change` when navigating to a different slide
+
+
+## Tests
+```
+npm t
+```
+
+## License
+MIT
\ No newline at end of file
diff --git a/packages/gallery/__tests__/jest/announcements.js b/packages/gallery/__tests__/jest/announcements.js
new file mode 100644
index 00000000..893effd5
--- /dev/null
+++ b/packages/gallery/__tests__/jest/announcements.js
@@ -0,0 +1,318 @@
+import gallery from '../../src';
+
+describe('Gallery > announcements', () => {
+
+ it('should warn if no live region is found', () => {
+
+ document.body.innerHTML = `
+
Gallery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ console.warn = jest.fn();
+ gallery('.js-gallery');
+ expect(console.warn).toHaveBeenCalledWith('A live region announcing current and total items is recommended for screen readers.');
+
+ });
+
+ it('Should update the live region when navigation occurs', () => {
+
+ document.body.innerHTML = `
+
Gallery
+
+
1 of 3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ const [ instance ] = gallery('.js-gallery');
+
+ expect(instance.getState().dom.liveRegion.textContent).toEqual('1 of 3');
+ instance.goTo(1);
+ expect(instance.getState().dom.liveRegion.textContent).toEqual('2 of 3');
+ instance.getState().dom.next.click();
+ expect(instance.getState().dom.liveRegion.textContent).toEqual('3 of 3');
+
+ });
+
+ it('Should render a custom announcement based on settings', () => {
+ document.location.hash = ''; //reset hash
+ document.body.innerHTML = `
+
Gallery
+
+
Now viewing item 1 of 3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ const [ instance ] = gallery('.js-gallery', {
+ announcement(current, total) {
+ return `Now viewing item ${current} of ${total}`;
+ }
+ });
+
+ expect(instance.getState().dom.liveRegion.textContent).toEqual('Now viewing item 1 of 3');
+ instance.goTo(1);
+ expect(instance.getState().dom.liveRegion.textContent).toEqual('Now viewing item 2 of 3');
+ instance.getState().dom.next.click();
+ expect(instance.getState().dom.liveRegion.textContent).toEqual('Now viewing item 3 of 3');
+
+ });
+
+});
\ No newline at end of file
diff --git a/packages/gallery/__tests__/jest/api.js b/packages/gallery/__tests__/jest/api.js
new file mode 100644
index 00000000..8d92294d
--- /dev/null
+++ b/packages/gallery/__tests__/jest/api.js
@@ -0,0 +1,146 @@
+import gallery from '../../src';
+import defaults from '../../src/lib/defaults';
+
+let instance;
+
+beforeAll(() => {
+ document.body.innerHTML = `
+
Gallery
+
+
1 of 5
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ [ instance ] = gallery('.js-gallery');
+});
+
+describe('Gallery > API', () => {
+
+ it('Should have an API method getState', () => {
+ const node = document.querySelector('.js-gallery');
+ expect(instance.getState).toBeDefined();
+ expect(instance.getState().node).toEqual(node);
+ expect(instance.getState().activeIndex).toEqual(0);
+ });
+
+ it('Should have an API method initialise', () => {
+ expect(instance.initialise).toBeDefined();
+ //we will test the initialise function in the initialisation tests
+ });
+
+ it('Should have an API method goTo', () => {
+ expect(instance.goTo).toBeDefined();
+ expect(instance.getState().activeIndex).toEqual(0);
+ instance.goTo(2);
+ expect(instance.getState().activeIndex).toEqual(2);
+ const galleryItems = Array.from(document.querySelectorAll(defaults.selector.item));
+ expect(galleryItems[0].classList.contains(defaults.className.active)).toEqual(false);
+ expect(galleryItems[0].hasAttribute('aria-hidden')).toEqual(true);
+ expect(galleryItems[2].classList.contains(defaults.className.active)).toEqual(true);
+ expect(galleryItems[2].hasAttribute('aria-hidden')).toEqual(false);
+ });
+
+ it('Should have an API method toggleFullScreen', () => {
+ expect(instance.toggleFullScreen).toBeDefined();
+ const { node } = instance.getState();
+ node.requestFullscreen = jest.fn();
+ instance.toggleFullScreen();
+ expect(node.requestFullscreen).toHaveBeenCalled();
+ });
+
+});
diff --git a/packages/gallery/__tests__/jest/events.js b/packages/gallery/__tests__/jest/events.js
new file mode 100644
index 00000000..4e4e63c8
--- /dev/null
+++ b/packages/gallery/__tests__/jest/events.js
@@ -0,0 +1,85 @@
+import gallery from '../../src';
+import { EVENTS } from '../../src/lib/constants';
+
+describe('Gallery > Events', () => {
+
+
+ it('should dispatch an initialisation event when initialised', () => {
+ document.body.innerHTML = `
+