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..9b3595db
--- /dev/null
+++ b/packages/gallery/README.md
@@ -0,0 +1,181 @@
+# 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 announcements.
+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"`.
+
+```
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+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
+ list: '[data-gallery-list]',
+ 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 the 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..a9fea4f5
--- /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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ 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..88a36fc7
--- /dev/null
+++ b/packages/gallery/__tests__/jest/api.js
@@ -0,0 +1,135 @@
+import gallery from '../../src';
+
+let instance;
+
+beforeAll(() => {
+ document.body.innerHTML = `
+ Gallery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ [ 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);
+ });
+
+ 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..4b973006
--- /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 = `
+ Gallery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ const node = document.querySelector('.js-gallery');
+ const initialisationHandler = jest.fn();
+ node.addEventListener(EVENTS.INITIALISED, initialisationHandler);
+ const [ instance ] = gallery('.js-gallery');
+ expect(initialisationHandler).toHaveBeenCalled();
+
+ const changeHandler = jest.fn();
+ node.addEventListener(EVENTS.CHANGE, changeHandler);
+ instance.goTo(1);
+ expect(changeHandler).toHaveBeenCalled();
+ });
+
+});
\ No newline at end of file
diff --git a/packages/gallery/__tests__/jest/fromURL.js b/packages/gallery/__tests__/jest/fromURL.js
new file mode 100644
index 00000000..2aac55ec
--- /dev/null
+++ b/packages/gallery/__tests__/jest/fromURL.js
@@ -0,0 +1,118 @@
+import gallery from '../../src';
+
+let instance;
+
+beforeAll(() => {
+ document.body.innerHTML = `
+ Gallery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+});
+
+describe('Gallery > from URL', () => {
+
+ it('Should set the initial activeIndex from the URL', () => {
+ window.location.href = `#gallery-1-3`;
+ [ instance ] = gallery('.js-gallery');
+ expect(instance.getState().activeIndex).toBe(2);
+ });
+
+});
\ No newline at end of file
diff --git a/packages/gallery/__tests__/jest/hashchange.js b/packages/gallery/__tests__/jest/hashchange.js
new file mode 100644
index 00000000..e81cbb3d
--- /dev/null
+++ b/packages/gallery/__tests__/jest/hashchange.js
@@ -0,0 +1,132 @@
+import gallery from '../../src';
+
+let instance;
+
+beforeEach(() => {
+ document.body.innerHTML = `
+ Gallery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ [ instance ] = gallery('.js-gallery', { updateURL: true });
+});
+
+describe('Gallery > hashchange', () => {
+
+
+ it('Should not update the gallery on hashchange if a new hash does not match the addressing spec', () => {
+ expect(instance.getState().activeIndex).toBe(0);
+ window.dispatchEvent(new HashChangeEvent('hashchange', { newURL: '#test' }));
+ expect(instance.getState().activeIndex).toBe(0);
+ });
+
+ it('Should not update the gallery on hashchange if the event has no state', () => {
+ expect(instance.getState().activeIndex).toBe(0);
+ window.dispatchEvent(new HashChangeEvent('hashchange'));
+ expect(instance.getState().activeIndex).toBe(0);
+ });
+
+ it('Should update the gallery on hashchange if a new hash matches the addressing spec', () => {
+ expect(instance.getState().activeIndex).toBe(0);
+ window.dispatchEvent(new HashChangeEvent('hashchange', { newURL: '#gallery-1-2' }));
+ expect(instance.getState().activeIndex).toBe(1);
+ });
+
+});
\ No newline at end of file
diff --git a/packages/gallery/__tests__/jest/initialisation/index.js b/packages/gallery/__tests__/jest/initialisation/index.js
new file mode 100644
index 00000000..5ed8ceef
--- /dev/null
+++ b/packages/gallery/__tests__/jest/initialisation/index.js
@@ -0,0 +1,257 @@
+import gallery from '../../../src';
+
+describe('Gallery > initialisation', () => {
+
+ it('Should console.warn if nothing found', () => {
+ document.body.innerHTML = ``;
+ console.warn = jest.fn();
+ gallery('.js-gallery');
+ expect(console.warn).toHaveBeenCalledWith("Gallery not initialised, no elements found for selector '.js-gallery'");
+ });
+
+ it('Should console.warn if no items are found', () => {
+ document.body.innerHTML = `
+ Gallery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ console.warn = jest.fn();
+ gallery('.js-gallery');
+ expect(console.warn).toHaveBeenCalledWith('Gallery cannot be initialised, no items found');
+ });
+
+
+ it('Should return an array of gallery objects', () => {
+ document.body.innerHTML = `
+ Gallery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ const instances = gallery('.js-gallery');
+ expect(Array.isArray(instances)).toBe(true);
+ expect(instances.length).toEqual(1);
+ expect(instances[0].getState).toBeDefined();
+ expect(instances[0].initialise).toBeDefined();
+ });
+
+ it('Should return an array of gallery objects with the DOM captured in state', () => {
+ document.body.innerHTML = `
+ Gallery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ const [ instance ] = gallery('.js-gallery');
+ expect(instance.getState).toBeDefined();
+ expect(instance.initialise).toBeDefined();
+ const state = instance.getState();
+ expect(state.dom.liveRegion).toBeDefined();
+ expect(state.dom.fullscreen).toBeDefined();
+ expect(state.dom.previous).toBeDefined();
+ expect(state.dom.next).toBeDefined();
+
+
+ });
+
+});
+
diff --git a/packages/gallery/__tests__/jest/initialisation/manual.js b/packages/gallery/__tests__/jest/initialisation/manual.js
new file mode 100644
index 00000000..72be1d60
--- /dev/null
+++ b/packages/gallery/__tests__/jest/initialisation/manual.js
@@ -0,0 +1,101 @@
+import gallery from '../../../src';
+import { EVENTS } from '../../../src/lib/constants';
+
+describe('Gallery > initialisation > manual initialisation', () => {
+ //mock image complete because JSDom cannot load images
+ beforeAll(() => {
+ Object.defineProperty(Image.prototype, 'complete', {
+ get() {
+ return true;
+ }
+ });
+ });
+
+ it('Should not set an active item, nor load any images until manually initialised', async () => {
+ document.body.innerHTML = `
+ Gallery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const node = document.querySelector('.js-gallery');
+ const initialisationHandler = jest.fn();
+ node.addEventListener(EVENTS.INITIALISED, initialisationHandler);
+ const [ instance ] = gallery('.js-gallery', { manualInitialisation: true });
+ expect(instance.getState).toBeDefined();
+ expect(instance.getState().items).toBeDefined();
+ expect(initialisationHandler).not.toHaveBeenCalled();
+
+ await instance.initialise();
+ expect(initialisationHandler).toHaveBeenCalled();
+ });
+
+});
\ No newline at end of file
diff --git a/packages/gallery/__tests__/jest/initialisation/multiple.js b/packages/gallery/__tests__/jest/initialisation/multiple.js
new file mode 100644
index 00000000..907d269b
--- /dev/null
+++ b/packages/gallery/__tests__/jest/initialisation/multiple.js
@@ -0,0 +1,324 @@
+import gallery from '../../../src';
+
+describe('Gallery > initialisation > multiple', () => {
+
+ it('Should return an array of gallery objects each with a distinct API affecting only that instance', () => {
+ document.body.innerHTML = `
+ Gallery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Gallery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ const instances = gallery('.js-gallery');
+ expect(Array.isArray(instances)).toBe(true);
+ expect(instances.length).toEqual(2);
+
+ //API call to one instance
+ expect(instances[0].getState().activeIndex).toEqual(0);
+ expect(instances[1].getState().activeIndex).toEqual(0);
+ instances[0].goTo(1);
+ expect(instances[0].getState().activeIndex).toEqual(1);
+ expect(instances[1].getState().activeIndex).toEqual(0);
+ });
+
+ it('Should initialise separate instances with different settings', () => {
+ document.body.innerHTML = `
+ Gallery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Gallery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ const [ one ] = gallery('.js-gallery__1');
+ const [ two ] = gallery('.js-gallery__2', { startIndex: 1 });
+
+ expect(one.getState().activeIndex).toEqual(0);
+ expect(two.getState().activeIndex).toEqual(1);
+ expect(one.getState().settings !== two.getState().settings).toBe(true);
+ });
+
+
+});
\ No newline at end of file
diff --git a/packages/gallery/__tests__/jest/interactions.js b/packages/gallery/__tests__/jest/interactions.js
new file mode 100644
index 00000000..de5669de
--- /dev/null
+++ b/packages/gallery/__tests__/jest/interactions.js
@@ -0,0 +1,143 @@
+import gallery from '../../src';
+
+let instance;
+
+beforeAll(() => {
+ document.body.innerHTML = `1
+ 2
+
+ Gallery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ [ instance ] = gallery('.js-gallery');
+});
+
+describe('Gallery > click interactions', () => {
+
+ it('should navigate to the next item via the next button', () => {
+ expect(instance.getState().activeIndex).toEqual(0);
+ instance.getState().dom.next.click();
+ expect(instance.getState().activeIndex).toEqual(1);
+ instance.getState().dom.next.click();
+ expect(instance.getState().activeIndex).toEqual(2);
+ instance.getState().dom.next.click();
+ expect(instance.getState().activeIndex).toEqual(0);
+ });
+
+ it('should navigate to the previous item via the previous button', () => {
+ expect(instance.getState().activeIndex).toEqual(0);
+ instance.getState().dom.previous.click();
+ expect(instance.getState().activeIndex).toEqual(2);
+ instance.getState().dom.previous.click();
+ expect(instance.getState().activeIndex).toEqual(1);
+ instance.getState().dom.previous.click();
+ expect(instance.getState().activeIndex).toEqual(0);
+ });
+
+ it('should navigate to a specific item via a navigation button', () => {
+ expect(instance.getState().activeIndex).toEqual(0);
+ document.querySelector('[data-gallery-navigate="1"]').click();
+ expect(instance.getState().activeIndex).toEqual(1);
+ document.querySelector('[data-gallery-navigate="2"]').click();
+ expect(instance.getState().activeIndex).toEqual(2);
+ });
+
+});
\ No newline at end of file
diff --git a/packages/gallery/__tests__/jest/store.js b/packages/gallery/__tests__/jest/store.js
new file mode 100644
index 00000000..9bad15ed
--- /dev/null
+++ b/packages/gallery/__tests__/jest/store.js
@@ -0,0 +1,40 @@
+import { createStore } from '../../src/lib/store';
+
+describe(`Gallery > store`, () => {
+
+ const store = createStore();
+ let effect = false;
+ const sideEffect = state => {
+ effect = !effect;
+ };
+
+ it('createstore should return an Object with an API', async () => {
+ expect(store).not.toBeNull();
+ expect(store.getState).not.toBeNull();
+ expect(store.update).not.toBeNull();
+ });
+
+ it('should have a getState function that returns a private state Object', async () => {
+ expect(store.state).toBeUndefined();
+ expect(store.getState()).toEqual({});
+ });
+
+ it('should have a dispatch function that updates state', async () => {
+ const nextState = { isOpen: true };
+ store.update(nextState);
+ expect(store.getState()).toEqual(nextState);
+ });
+
+ it('should have a dispatch function that does not update state if nextState is not passed', async () => {
+ const store = createStore();
+ store.update();
+ expect(store.getState()).toEqual({});
+ });
+
+ it('should have a dispatch function that invokes any side effect functions passed after the state change, with new state as only argument', async () => {
+ store.update({}, [sideEffect]);
+ expect(effect).toEqual(true);
+ });
+
+
+});
diff --git a/packages/gallery/__tests__/jest/updateURL.js b/packages/gallery/__tests__/jest/updateURL.js
new file mode 100644
index 00000000..2555b5d3
--- /dev/null
+++ b/packages/gallery/__tests__/jest/updateURL.js
@@ -0,0 +1,124 @@
+import gallery from '../../src';
+
+let instance;
+
+beforeAll(() => {
+ document.body.innerHTML = `
+ Gallery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ [ instance ] = gallery('.js-gallery', { updateURL: true });
+});
+
+describe('Gallery > updateURL', () => {
+
+ it('Should update the URL when navigating the gallery', () => {
+ instance.goTo(2);
+ // const expected = 'http://localhost:8080/gallery/1';
+ // const url = new URL(window.location.href);
+ // url.hash = '#1';
+ // window.history.pushState({}, '', url.href);
+ expect(window.location.hash).toBe('#gallery-1-3');
+ instance.getState().dom.next.click();
+ expect(window.location.hash).toBe('#gallery-1-1');
+ });
+
+});
\ No newline at end of file
diff --git a/packages/gallery/__tests__/jest/utils.js b/packages/gallery/__tests__/jest/utils.js
new file mode 100644
index 00000000..ba69da82
--- /dev/null
+++ b/packages/gallery/__tests__/jest/utils.js
@@ -0,0 +1,469 @@
+import { sanitise, getIndexFromURL, getSelection, throttle, patchIds } from '../../src/lib/utils';
+
+describe('Gallery > Utils > sanitize', () => {
+
+ it('should replace ampersands with HTML entity', () => {
+ expect(sanitise('test&test&test&test')).toEqual('test&test&test&test');
+ });
+
+ it('should replace code block open braces with HTML entity less than', () => {
+ expect(sanitise(' {
+ expect(sanitise('test>')).toEqual('test>');
+ });
+
+ it('should replace ampersands, open, and close blocks with non-JS executable HTML entities', () => {
+ expect(sanitise(' Image alert')).toEqual('<img src="x" onerror="alert(1)" >Image alert');
+ });
+
+});
+
+describe('Gallery > getIndexFromURL', () => {
+
+ it('Should return fallback if no hash', () => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ const result = getIndexFromURL([], '');
+ expect(result).toEqual(false);
+ warn.mockRestore();
+ });
+
+ it('Should return fallback if hash does not contain gallery item id', () => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ const result = getIndexFromURL([], '#potato');
+ expect(result).toEqual(false);
+ warn.mockRestore();
+ });
+
+ it('Should return index if one is found', () => {
+ const result = getIndexFromURL([
+ { id: 'gallery-1-1' },
+ { id: 'gallery-1-2' }], '#gallery-1-2');
+
+ expect(result).toEqual(1);
+ });
+
+ it('Should return fallback passed as argument if no item matching hash', () => {
+ const result = getIndexFromURL([], '#gallery-1-2', 'fallback');
+
+ expect(result).toEqual('fallback');
+ });
+
+
+});
+
+
+describe('Gallery > Utils > Get Selection', () => {
+
+ const setupDOM = () => {
+ document.body.innerHTML = `
`;
+ };
+
+ beforeAll(setupDOM);
+
+ it('should return an array when passed a DOM element', async () => {
+ const node = document.querySelector('.js-gallery');
+ const els = getSelection(node);
+ expect(els instanceof Array).toBe(true);
+ expect(els.length).toEqual(1);
+ });
+
+ it('should return an array when passed a NodeList element', async () => {
+ const node = document.querySelectorAll('.js-gallery');
+ const els = getSelection(node);
+ expect(els instanceof Array).toBe(true);
+ expect(els.length).toEqual(1);
+ });
+
+ it('should return an array when passed an array of DOM elements', async () => {
+ const node = document.querySelector('.js-gallery');
+ const els = getSelection([node]);
+ expect(els instanceof Array).toBe(true);
+ expect(els.length).toEqual(1);
+ });
+
+ it('should return an array when passed a string', async () => {
+ const els = getSelection('.js-gallery');
+ expect(els instanceof Array).toBe(true);
+ expect(els.length).toEqual(1);
+ });
+
+});
+
+
+describe('Gallery > Utils > Throttle', () => {
+ jest.useFakeTimers();
+ let mockFn;
+ let throttledFn;
+
+ beforeEach(() => {
+ mockFn = jest.fn();
+ throttledFn = throttle(mockFn, 1000); // 1-second boundary
+ });
+
+ it('should call the function immediately on the first invocation', () => {
+ throttledFn();
+ expect(mockFn).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not call the function again within the boundary time', () => {
+ throttledFn();
+ throttledFn();
+ jest.advanceTimersByTime(500); // Half the boundary time
+ throttledFn();
+ expect(mockFn).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call the function again after the boundary time', () => {
+ throttledFn();
+ jest.advanceTimersByTime(1001); // Full boundary time plus 1
+ throttledFn();
+ expect(mockFn).toHaveBeenCalledTimes(2);
+ });
+
+ it('should handle rapid consecutive calls correctly', () => {
+ throttledFn();
+ throttledFn();
+ throttledFn();
+ jest.advanceTimersByTime(1001);
+ throttledFn();
+ expect(mockFn).toHaveBeenCalledTimes(2);
+ });
+
+ it('should reset the timer after each valid call', () => {
+ throttledFn();
+ jest.advanceTimersByTime(1001);
+ throttledFn();
+ jest.advanceTimersByTime(1001);
+ throttledFn();
+ expect(mockFn).toHaveBeenCalledTimes(3);
+ });
+});
+
+
+describe('Gallery > Utils > patchIds', () => {
+
+ it('should respect authored item ids, but warn if duplicate', () => {
+
+ document.body.innerHTML = `
+
+
+
Gallery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+
+ console.warn = jest.fn();
+ const items = Array.from(document.querySelectorAll('.gallery__item'));
+ patchIds(items, 0);
+ expect(console.warn).toHaveBeenCalledWith('Gallery item id "first" is not unique, please ensure each gallery item has a unique id');
+
+ });
+
+ it('should auto-generate ids on items that are missing them', () => {
+ document.body.innerHTML = `
+
+
Gallery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+
+ const items = Array.from(document.querySelectorAll('.gallery__item'));
+ patchIds(items, 0);
+ expect(items[0].id).toEqual('gallery-1-1');
+ expect(items[1].id).toEqual('gallery-1-2');
+
+ });
+
+ it('should auto-generate ids on items that are missing them, managing uniqueness across multiple invocations', () => {
+ document.body.innerHTML = `
+
+
Gallery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Gallery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+
+ const firstGroup = Array.from(document.querySelectorAll('.js-gallery .gallery__item'));
+ const secondGroup = Array.from(document.querySelectorAll('.js-gallery-2 .gallery__item'));
+ patchIds(firstGroup, 0);
+ patchIds(secondGroup, 0);
+ expect(firstGroup[0].id).toEqual('gallery-1-1');
+ expect(firstGroup[1].id).toEqual('gallery-1-2');
+ expect(secondGroup[0].id).toEqual('gallery-101-1');
+ expect(secondGroup[1].id).toEqual('gallery-101-2');
+
+ });
+
+});
\ No newline at end of file
diff --git a/packages/gallery/__tests__/playwright/playwright.spec.js b/packages/gallery/__tests__/playwright/playwright.spec.js
new file mode 100644
index 00000000..787feed9
--- /dev/null
+++ b/packages/gallery/__tests__/playwright/playwright.spec.js
@@ -0,0 +1,75 @@
+const { test, expect } = require('@playwright/test');
+import defaults from '../../src/lib/defaults';
+import AxeBuilder from '@axe-core/playwright';
+
+test.beforeEach(async ({ page }) => {
+ await page.goto('/');
+});
+
+test.describe('Gallery > Keyboard Navigation', { tag: '@all' }, () => {
+ test('Should navigate to the next item when the right arrow key is pressed', async ({ page }) => {
+ const list = await page.locator('[data-gallery-list]');
+ const items = await page.locator('[data-gallery-item]').all();
+ await list.focus();
+ await expect(items[0]).toHaveClass(`gallery__item ${defaults.className.active}`);
+ await page.keyboard.press('ArrowRight');
+ await expect(items[1]).toHaveClass(`gallery__item ${defaults.className.active}`);
+ await page.keyboard.press('ArrowRight');
+ await expect(items[2]).toHaveClass(`gallery__item ${defaults.className.active}`);
+ });
+
+ test('Should navigate to the previous item when the left arrow key is pressed', async ({ page }) => {
+ const list = await page.locator('[data-gallery-list]');
+ const items = await page.locator('[data-gallery-item]').all();
+ await list.focus();
+ await expect(items[0]).toHaveClass(`gallery__item ${defaults.className.active}`);
+ await page.keyboard.press('ArrowLeft');
+ await expect(items[items.length - 1]).toHaveClass(`gallery__item ${defaults.className.active}`);
+ await page.keyboard.press('ArrowLeft');
+ await expect(items[items.length - 2]).toHaveClass(`gallery__item ${defaults.className.active}`);
+ });
+});
+
+test.describe('Gallery > Full screen mode support', { tag: '@all' }, () => {
+ test('Should update the URL when item changes', async ({ page }) => {
+ const list = await page.locator('[data-gallery-list]');
+ await list.focus();
+ await page.keyboard.press('ArrowRight');
+ await expect(page.url()).toContain('#gallery-1-2');
+ await page.keyboard.press('ArrowRight');
+ await expect(page.url()).toContain('#gallery-1-3');
+ });
+});
+
+test.describe('Gallery > scrolling support', { tag: '@desktop' }, () => {
+ test('Should update the active item when scrolled to', async ({ page }) => {
+ const list = await page.locator('[data-gallery-list]');
+ const items = await page.locator('[data-gallery-item]').all();
+ const itemWidth = await items[0].evaluate((node) => node.offsetWidth);
+ await expect(items[0]).toHaveClass(`gallery__item ${defaults.className.active}`);
+ await list.hover();
+ await page.mouse.wheel(itemWidth/.75, 0);
+ await items[1].scrollIntoViewIfNeeded();
+ await expect(items[1]).toHaveClass(`gallery__item ${defaults.className.active}`);
+ });
+});
+
+
+test.describe('Gallery > Full screen mode support', { tag: '@all' }, () => {
+ test('Should remove the fullscreen button if not supported', async ({ page }) => {
+ const supportsFullscreen = await page.evaluate(() => document.fullscreenEnabled || document.webkitFullscreenEnabled);
+ if (!supportsFullscreen) {
+ await expect(page.locator('[data-gallery-fullscreen]')).not.toBeVisible();
+ } else {
+ await expect(page.locator('[data-gallery-fullscreen]')).toBeVisible();
+ }
+ });
+});
+
+test.describe('Gallery > Axe', { tag: '@reduced' }, () => {
+ test('Should not have any automatically detectable accessibility issues', async ({ page }) => {
+ const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
+ expect(accessibilityScanResults.violations).toEqual([]);
+ });
+});
+
diff --git a/packages/gallery/example/src/favicon.ico b/packages/gallery/example/src/favicon.ico
new file mode 100644
index 00000000..23d3ceaa
Binary files /dev/null and b/packages/gallery/example/src/favicon.ico differ
diff --git a/packages/gallery/example/src/index.html b/packages/gallery/example/src/index.html
new file mode 100644
index 00000000..1076bf0f
--- /dev/null
+++ b/packages/gallery/example/src/index.html
@@ -0,0 +1,401 @@
+
+
+
+ StormID
+
+
+
+
+
+ Gallery example
+
+
Gallery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/gallery/example/src/js/index.js b/packages/gallery/example/src/js/index.js
new file mode 100644
index 00000000..d833f8c3
--- /dev/null
+++ b/packages/gallery/example/src/js/index.js
@@ -0,0 +1,3 @@
+import gallery from '../../../src';
+
+const [ instance ] = gallery('.js-gallery');
\ No newline at end of file
diff --git a/packages/gallery/example/webpack.config.js b/packages/gallery/example/webpack.config.js
new file mode 100644
index 00000000..a2d72d75
--- /dev/null
+++ b/packages/gallery/example/webpack.config.js
@@ -0,0 +1,42 @@
+const path = require('path');
+const webpack = require('webpack');
+const CleanWebpackPlugin = require('clean-webpack-plugin');
+const HtmlWebpackPlugin = require('html-webpack-plugin');
+const pkg = require('../package.json');
+
+module.exports = {
+ entry: './example/src/js/index.js',
+ output: {
+ filename: 'app.js',
+ path: path.resolve(__dirname, './build')
+ },
+ mode: 'development',
+ devtool: 'source-map',
+ devServer: {
+ port: 8081
+ },
+ plugins: [
+ new webpack.HotModuleReplacementPlugin(),
+ new CleanWebpackPlugin(['./build']),
+ new HtmlWebpackPlugin({
+ title: pkg.name,
+ template: './example/src/index.html',
+ filename: 'index.html'
+ })
+ ],
+ module: {
+ rules: [{
+ test: /\.js$/,
+ exclude: /(node_modules|bower_components)/,
+ use: {
+ loader: 'babel-loader',
+ }
+ },
+ {
+ test: /\.(ico)$/,
+ use: {
+ loader: 'file-loader'
+ }
+ }]
+ }
+};
diff --git a/packages/gallery/jest.config.js b/packages/gallery/jest.config.js
new file mode 100644
index 00000000..05652709
--- /dev/null
+++ b/packages/gallery/jest.config.js
@@ -0,0 +1,12 @@
+const base = require('../../tools/jest/config.base.js');
+const pack = require('./package');
+
+module.exports = {
+ ...base,
+ transform: {
+ '^.+\\.js$': '../../tools/jest/babel-jest-wrapper.js'
+ },
+ displayName: pack.name,
+ name: pack.name,
+ testEnvironment: 'jsdom'
+};
\ No newline at end of file
diff --git a/packages/gallery/package.json b/packages/gallery/package.json
new file mode 100644
index 00000000..0456e64d
--- /dev/null
+++ b/packages/gallery/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "@stormid/gallery",
+ "version": "1.0.0",
+ "description": "Accessible image gallery",
+ "author": "stormid",
+ "license": "MIT",
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": "https://github.com/stormid/components/tree/master/packages/gallery",
+ "private": false,
+ "main": "dist/index.js",
+ "source": "src/index.js",
+ "module": "dist/index.modern.mjs",
+ "unpkg": "dist/index.umd.js",
+ "browser": "dist/index.umd.js",
+ "keywords": [
+ "stormid",
+ "component",
+ "ui",
+ "gallery"
+ ],
+ "scripts": {
+ "build": "npx microbundle --name Gallery",
+ "test": "jest --coverage & npx playwright test",
+ "dev": "webpack-dev-server --config example/webpack.config.js --mode development",
+ "prod": "webpack --config example/webpack.config.js --mode production",
+ "prepublish": "npm run -s build"
+ }
+}
diff --git a/packages/gallery/playwright.config.js b/packages/gallery/playwright.config.js
new file mode 100644
index 00000000..b9c22fb4
--- /dev/null
+++ b/packages/gallery/playwright.config.js
@@ -0,0 +1,16 @@
+
+const { defineConfig } = require('@playwright/test');
+const baseConfig = require('../../tools/playwright/config.base.js');
+const server = require('./tools/playwright.webpack.config.js');
+
+module.exports = defineConfig({
+ ...baseConfig,
+ use: {
+ ...baseConfig.use,
+ baseURL: `http://127.0.0.1:${server.devServer.port}/`,
+ },
+ webServer: {
+ ...baseConfig.webServer,
+ url: `http://127.0.0.1:${server.devServer.port}/`,
+ },
+});
\ No newline at end of file
diff --git a/packages/gallery/src/index.js b/packages/gallery/src/index.js
new file mode 100644
index 00000000..0a7dc233
--- /dev/null
+++ b/packages/gallery/src/index.js
@@ -0,0 +1,10 @@
+import defaults from './lib/defaults';
+import factory from './lib/factory';
+import { getSelection } from './lib/utils';
+
+export default (selector, options) => {
+ const galleries = getSelection(selector);
+ if (galleries.length === 0) return void console.warn(`Gallery not initialised, no elements found for selector '${selector}'`);
+
+ return galleries.map((gallery, index) => Object.create(factory(gallery, { ...defaults, ...options }, index)));
+};
\ No newline at end of file
diff --git a/packages/gallery/src/lib/constants.js b/packages/gallery/src/lib/constants.js
new file mode 100644
index 00000000..32e84648
--- /dev/null
+++ b/packages/gallery/src/lib/constants.js
@@ -0,0 +1,15 @@
+/* istanbul ignore file */
+export const ATTRIBUTE = {
+ NAVIGATE: 'data-gallery-navigate'
+};
+
+export const EVENTS = {
+ INITIALISED: 'gallery.initialised',
+ CHANGE: 'gallery.change'
+};
+
+export const KEYCODES = {
+ LEFT: 37,
+ RIGHT: 39,
+ // TAB: 9
+};
\ No newline at end of file
diff --git a/packages/gallery/src/lib/defaults.js b/packages/gallery/src/lib/defaults.js
new file mode 100644
index 00000000..530d6d3d
--- /dev/null
+++ b/packages/gallery/src/lib/defaults.js
@@ -0,0 +1,23 @@
+/* istanbul ignore file */
+
+export default {
+ startIndex: 0,
+ selector: {
+ list: '[data-gallery-list]',
+ 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: {
+ active: 'is--active',
+ fullscreen: 'is--fullscreen'
+ },
+ manualInitialisation: false, //if the gallery is hidden (e.g. in a modals) we may wish to delay initialisation
+ updateURL: true, //change URL when item changes
+ announcement(current, total){
+ return `${current} of ${total}`;
+ }
+};
\ No newline at end of file
diff --git a/packages/gallery/src/lib/dom.js b/packages/gallery/src/lib/dom.js
new file mode 100644
index 00000000..7849cff8
--- /dev/null
+++ b/packages/gallery/src/lib/dom.js
@@ -0,0 +1,102 @@
+import { sanitise, updateCSS } from './utils';
+import { ATTRIBUTE, EVENTS, KEYCODES } from './constants';
+
+export const init = store => () => {
+ const state = store.getState();
+ const { settings, dom, list } = state;
+
+ if (!dom.liveRegion) console.warn(`A live region announcing current and total items is recommended for screen readers.`);
+ else writeLiveRegion(state);
+ /* istanbul ignore next */
+ if (dom.fullscreen) {
+ if (document.fullscreenEnabled || document.webkitFullscreenEnabled) {
+ dom.fullscreen.addEventListener('click', toggleFullScreen.bind(null, store));
+ document.addEventListener('fullscreenchange', e => {
+ if (document.fullscreenElement) document.documentElement.classList.add(settings.className.fullscreen);
+ else document.documentElement.classList.remove(settings.className.fullscreen);
+ });
+ } else dom.fullscreen.parentNode.removeChild(dom.fullscreen);
+ }
+ if (dom.previous) dom.previous.addEventListener('click', previous.bind(null, store));
+ if (dom.next) dom.next.addEventListener('click', next.bind(null, store));
+ if (dom.triggers.length) {
+ dom.triggers.forEach(trigger => trigger.addEventListener('click', () => {
+ goTo(store)(+trigger.getAttribute(ATTRIBUTE.NAVIGATE));
+ }));
+ }
+
+ /* istanbul ignore next */
+ list.addEventListener('keydown', e => {
+ switch (e.keyCode) {
+ case KEYCODES.LEFT:
+ e.preventDefault();
+ previous(store);
+ break;
+ case KEYCODES.RIGHT:
+ e.preventDefault();
+ next(store);
+ break;
+ // case KEYCODES.TAB:
+ // next(store);
+ // break;
+ }
+ });
+
+ updateCSS(state);
+ broadcast(store, EVENTS.INITIALISED)(state);
+};
+
+const writeLiveRegion = ({ activeIndex, items, settings, dom }) => dom.liveRegion.innerHTML = sanitise(settings.announcement(activeIndex + 1, items.length));
+
+/* istanbul ignore next */
+export const toggleFullScreen = store => {
+ const { node } = store.getState();
+ if (!document.fullscreenElement) {
+ if (node.requestFullscreen) node.requestFullscreen();
+ else if (node.webkitRequestFullscreen) node.webkitRequestFullscreen();
+ } else {
+ if (document.exitFullscreen) document.exitFullscreen();
+ else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
+ }
+};
+
+export const change = (store, next, options = { fromListener: false, fromScroll: false }) => {
+ const { list, items, settings } = store.getState();
+
+ store.update({ ...store.getState(), activeIndex: next }, [
+ () => {
+ if (!options.fromScroll) list.scrollLeft = (next * items[next].clientWidth);
+ },
+ writeLiveRegion,
+ updateCSS,
+ () => {
+ const id = items[next].getAttribute('id');
+ settings.updateURL && !options.fromListener && window.history.pushState({ URL: `#${id}` }, '', `#${id}`);
+ },
+ broadcast(store, EVENTS.CHANGE)
+ ]);
+};
+
+export const broadcast = (store, eventType) => state => {
+ const event = new CustomEvent(eventType, {
+ bubbles: true,
+ detail: {
+ getState: store.getState
+ }
+ });
+ state.node.dispatchEvent(event);
+};
+
+export const previous = store => {
+ const { activeIndex, items } = store.getState();
+ change(store, (activeIndex === 0 ? items.length - 1 : activeIndex - 1));
+};
+
+export const next = store => {
+ const { activeIndex, items } = store.getState();
+ change(store, (activeIndex === items.length - 1 ? 0 : activeIndex + 1));
+};
+
+export const goTo = store => i => {
+ change(store, i);
+};
\ No newline at end of file
diff --git a/packages/gallery/src/lib/factory.js b/packages/gallery/src/lib/factory.js
new file mode 100644
index 00000000..8b56c255
--- /dev/null
+++ b/packages/gallery/src/lib/factory.js
@@ -0,0 +1,40 @@
+import { createStore } from './store';
+import { init, toggleFullScreen, goTo } from './dom';
+import { composeDOM, getIndexFromURL, hashchangeHandler, scrollHandler, patchIds, throttle } from './utils';
+
+/*
+ * @param node, HTMLElement, DOM node containing the gallery
+ * @param settings, Object, merged defaults + options passed in as instantiation config to module default
+ *
+ * @returns Object, Gallery API: getState, initialise (for deferred or manual initialisation), gotTo, toggleFullScreen
+ */
+export default (node, settings, index) => {
+ const store = createStore();
+
+ let items = [].slice.call(node.querySelectorAll(settings.selector.item));
+ if (items.length === 0) return console.warn('Gallery cannot be initialised, no items found'), null;
+
+ //ensure each gallery item has an id
+ //need to do this before setting initial state as it's required for the initial activeIndex
+ items = patchIds(items, index);
+
+ store.update({
+ node,
+ settings,
+ items,
+ list: node.querySelector(settings.selector.list),
+ dom: composeDOM(node, settings),
+ activeIndex: getIndexFromURL(items, location.hash, settings.startIndex)
+ }, [
+ () => !settings.manualInitialisation && init(store)(),
+ () => window.addEventListener('hashchange', hashchangeHandler(store)),
+ ({ list }) => list.addEventListener('scroll', throttle(scrollHandler(store), 160))
+ ]);
+
+ return {
+ getState: store.getState,
+ initialise: init(store),
+ goTo: goTo(store),
+ toggleFullScreen: toggleFullScreen.bind(null, store)
+ };
+};
\ No newline at end of file
diff --git a/packages/gallery/src/lib/store.js b/packages/gallery/src/lib/store.js
new file mode 100644
index 00000000..b0a62a25
--- /dev/null
+++ b/packages/gallery/src/lib/store.js
@@ -0,0 +1,13 @@
+export const createStore = () => {
+ let state = {};
+
+ const getState = () => state;
+
+ const update = (nextState, effects) => {
+ state = nextState ?? state;
+ if (!effects) return;
+ effects.forEach(effect => effect(state));
+ };
+
+ return { update, getState };
+};
\ No newline at end of file
diff --git a/packages/gallery/src/lib/utils.js b/packages/gallery/src/lib/utils.js
new file mode 100644
index 00000000..a2fa32ea
--- /dev/null
+++ b/packages/gallery/src/lib/utils.js
@@ -0,0 +1,104 @@
+import { change } from './dom';
+
+export const sanitise = item => item.replace(/&/g, '&').replace(//g, '>');
+
+export const composeDOM = (node, settings) => ({
+ liveRegion: node.querySelector(settings.selector.liveRegion),
+ fullscreen: node.querySelector(settings.selector.fullscreen),
+ previous: node.querySelector(settings.selector.previous),
+ next: node.querySelector(settings.selector.next),
+ triggers: [].slice.call(document.querySelectorAll(settings.selector.navigate))
+});
+
+export const getIndexFromURL = (items, url, fallback = false) => {
+ const hash = url.split(`#`)[1] || '';
+ if (hash === '') return fallback;
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].id === hash) return i;
+ }
+ return fallback;
+};
+
+/* istanbul ignore next */
+export const getPosition = ({ list, items }) => {
+ const scrollPosition = list.scrollLeft;
+ const itemWidth = items[0].clientWidth;
+ if (scrollPosition === 0 && itemWidth === 0) return;
+ if (Number.isNaN(itemWidth) || Number.isNaN(scrollPosition)) return;
+ if (scrollPosition === 0) return 0;
+ return Math.round(scrollPosition / itemWidth);
+};
+
+export const hashchangeHandler = store => e => {
+ const url = e.newURL;
+ const { items } = store.getState();
+ const index = getIndexFromURL(items, url);
+ if (index === false) return;
+ change(store, index, { fromListener: true });
+};
+
+/* istanbul ignore next */
+export const scrollHandler = store => e => {
+ const { list, items, activeIndex } = store.getState();
+ const index = getPosition({ list, items } );
+ if (index === undefined) return;
+ if (index === activeIndex) return;
+ change(store, index, { fromScroll: true });
+};
+
+
+//ensure each gallery item has a unique id
+//used for updating the URL and for accessibility
+export const patchIds = (items, index) => items.map((item, idx) => {
+ // if the item has an id, but it is a duplicate, warn the user
+ if (item.hasAttribute('id')) {
+ if (Array.from(document.querySelectorAll(`#${item.id}`)).length > 1) {
+ console.warn(`Gallery item id "${item.id}" is not unique, please ensure each gallery item has a unique id`);
+ }
+ } else {
+ let id = `gallery-${index + 1}-${idx + 1}`;
+ // check for duplicate ids across whole document in case multiple galleries have been initialised separately, add 100 to index
+ while (document.getElementById(id)) {
+ id = `gallery-${(index + 100) + 1}-${idx + 1}`;
+ }
+ item.setAttribute('id', id);
+ }
+ return item;
+});
+
+export const updateCSS = ({ activeIndex, node, items, settings }) => {
+ node.querySelector(settings.className.active)?.classList.remove(settings.className.active);
+ items[activeIndex].classList.add(settings.className.active);
+};
+
+export const getSelection = selector => {
+ if (typeof selector === 'string') return [].slice.call(document.querySelectorAll(selector));
+ if (selector instanceof Array) return selector;
+ if (Object.prototype.isPrototypeOf.call(NodeList.prototype, selector)) return [].slice.call(selector);
+ if (selector instanceof HTMLElement) return [selector];
+ return [];
+};
+
+export function throttle (fn, boundary) {
+ let last = -Infinity;
+ let timer;
+ return function bounced () {
+ if (timer) {
+ return;
+ }
+ unbound();
+
+ function unbound () {
+ clearTimeout(timer);
+ timer = null;
+ let next = last + boundary;
+ let now = Date.now();
+ if (now > next) {
+ last = now;
+ fn();
+ } else {
+ timer = setTimeout(unbound, next - now);
+ }
+ }
+ };
+};
\ No newline at end of file
diff --git a/packages/gallery/tools/playwright.webpack.config.js b/packages/gallery/tools/playwright.webpack.config.js
new file mode 100644
index 00000000..b2741254
--- /dev/null
+++ b/packages/gallery/tools/playwright.webpack.config.js
@@ -0,0 +1,8 @@
+const baseConfig = require('./webpack.config');
+
+module.exports = {
+ ...baseConfig,
+ devServer: {
+ port: 8088
+ }
+};
diff --git a/packages/gallery/tools/webpack.config.js b/packages/gallery/tools/webpack.config.js
new file mode 100644
index 00000000..e04f3969
--- /dev/null
+++ b/packages/gallery/tools/webpack.config.js
@@ -0,0 +1,45 @@
+const path = require('path');
+const webpack = require('webpack');
+const CleanWebpackPlugin = require('clean-webpack-plugin');
+const HtmlWebpackPlugin = require('html-webpack-plugin');
+const pkg = require('../package.json');
+
+module.exports = {
+ entry: './example/src/js/index.js',
+ output: {
+ filename: 'app.js',
+ path: path.resolve(__dirname, '../build')
+ },
+ mode: 'development',
+ devtool: 'source-map',
+ devServer: {
+ port: 8081
+ },
+ plugins: [
+ new webpack.HotModuleReplacementPlugin(),
+ new CleanWebpackPlugin(['../build']),
+ new HtmlWebpackPlugin({
+ title: pkg.name,
+ template: './example/src/index.html',
+ filename: 'index.html'
+ })
+ ],
+ module: {
+ rules: [{
+ test: /\.js$/,
+ exclude: /(node_modules|bower_components)/,
+ use: {
+ loader: 'babel-loader',
+ options: {
+ rootMode: 'upward',
+ }
+ }
+ },
+ {
+ test: /\.(ico)$/,
+ use: {
+ loader: 'file-loader'
+ }
+ }]
+ }
+};