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 = ``; + 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 = ``; + 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 = ``; + 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 = ``; + [ 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 = ``; + 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 = ``; +}); + +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 = ``; + [ 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 = ``; + 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 = ``; + 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 = ``; + 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 = ``; + + 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 = ` + `; + 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 = ` + `; + 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 = ` + + `; + [ 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 = ``; + [ 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 = ` +
+ `; + + 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 = ` + `; + + 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 = ` + + `; + + 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

+ +
+ + 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' + } + }] + } +};