diff --git a/package.json b/package.json
index 5ad3fc6e..d4a12866 100644
--- a/package.json
+++ b/package.json
@@ -53,6 +53,7 @@
"glob": "^7.1.4",
"jest": "^24.9.0",
"jest-vue-preprocessor": "^1.5.0",
+ "lodash.merge": "^4.6.2",
"storybook-addon-vue-info": "1.4.2",
"storybook-vue-router": "^1.0.7",
"stylelint": "^11.0.0",
diff --git a/src/atoms/image/Image.html b/src/atoms/image/Image.html
new file mode 100644
index 00000000..dacc7c40
--- /dev/null
+++ b/src/atoms/image/Image.html
@@ -0,0 +1,13 @@
+
+
diff --git a/src/atoms/image/Image.js b/src/atoms/image/Image.js
new file mode 100644
index 00000000..90c9f0de
--- /dev/null
+++ b/src/atoms/image/Image.js
@@ -0,0 +1,33 @@
+import getClass from '../../../utils/helpers/get-class.js'
+
+export default {
+ mixins: [getClass],
+ inheritAttrs: false,
+ props: {
+ /**
+ * To use another tag instead of `img`, e.g. `picture`
+ */
+ tag: {
+ type: String,
+ default: 'img',
+ validator: tag => tag === 'img' || tag === 'picture'
+ }
+ },
+ data () {
+ return {
+ config: {
+ base: {
+ image: [
+ 'block',
+ 'max-w-full'
+ ]
+ }
+ }
+ }
+ },
+ computed: {
+ usePicture () {
+ return this.tag === 'picture'
+ }
+ }
+}
diff --git a/src/atoms/image/Image.spec.js b/src/atoms/image/Image.spec.js
new file mode 100644
index 00000000..1845f4cf
--- /dev/null
+++ b/src/atoms/image/Image.spec.js
@@ -0,0 +1,25 @@
+import { mount } from '@vue/test-utils'
+import AImage from './Image.vue'
+
+describe('Image', () => {
+ it('has default structure', () => {
+ const wrapper = mount(AImage, {
+ propsData: {
+ src: '/images/image/banner.jpg'
+ }
+ })
+
+ expect(wrapper.element.tagName).toBe('IMG')
+ })
+
+ it('should be generated with the `alt` passed as attributes', () => {
+ const wrapper = mount(AImage, {
+ attrs: {
+ alt: 'Sample Image'
+ }
+ })
+
+ expect(wrapper.attributes().alt).toBeDefined()
+ expect(wrapper.attributes().alt).toEqual('Sample Image')
+ })
+})
diff --git a/src/atoms/image/Image.stories.js b/src/atoms/image/Image.stories.js
new file mode 100644
index 00000000..648ffd87
--- /dev/null
+++ b/src/atoms/image/Image.stories.js
@@ -0,0 +1,160 @@
+import AImage from './Image.vue'
+import ALazyImage from './LazyImage.vue'
+
+export default {
+ title: 'Atoms/Image',
+ components: {
+ AImage,
+ ALazyImage
+ },
+ argTypes: {
+ src: {
+ control: {
+ type: 'text'
+ }
+ },
+ tag: {
+ control: {
+ type: 'text'
+ }
+ },
+ alt: {
+ control: {
+ type: 'text'
+ }
+ },
+ placeholderSrc: {
+ control: {
+ type: 'text'
+ }
+ }
+ }
+}
+
+const sources = [
+ {
+ src: 'images/image/banner-480_480.png',
+ width: '480px'
+ },
+ {
+ src: 'images/image/banner-768_402.png',
+ width: '768px'
+ },
+ {
+ src: 'images/image/banner-992_254.png',
+ width: '992px'
+ }
+]
+
+const TemplateImage = (args, { argTypes }) => ({
+ components: { AImage },
+ props: Object.keys(argTypes),
+ template: `
+
+ `
+})
+
+const TemplatePicture = (args, { argTypes }) => ({
+ components: { AImage },
+ props: Object.keys(argTypes),
+ template: `
+
+
+ `
+})
+
+export const Image = TemplateImage.bind({})
+Image.args = {
+ tag: 'img',
+ src: 'images/image/banner.jpg',
+ alt: 'alt text goes here'
+}
+
+export const Picture = TemplatePicture.bind({})
+Picture.args = {
+ tag: 'picture',
+ src: 'images/image/banner.jpg',
+ alt: 'alt text goes here'
+}
+
+const TemplateLazyImage = (args, { argTypes }) => ({
+ components: { ALazyImage },
+ props: Object.keys(argTypes),
+ template: `
+
+ `
+})
+
+export const LazyImage = TemplateLazyImage.bind({})
+LazyImage.args = {
+ tag: 'img',
+ src: 'images/image/banner.jpg',
+ alt: 'alt text goes here'
+}
+
+export const LazyImageWithPlaceholder = TemplateLazyImage.bind({})
+LazyImageWithPlaceholder.args = {
+ tag: 'img',
+ src: 'images/image/banner.jpg',
+ alt: 'alt text goes here',
+ placeholderSrc: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAG0lEQVQYV2M8c+bMfwZGRgbGs2fO/v/P8J8BAFA1CMsVPhxYAAAAAElFTkSuQmCC'
+}
+
+const TemplateLazyPicture = (args, { argTypes }) => ({
+ components: { ALazyImage },
+ props: Object.keys(argTypes),
+ template: `
+
+
+
+
+
+ Placeholder slot
+
+
+ `
+})
+export const LazyPicture = TemplateLazyPicture.bind({})
+LazyPicture.args = {
+ tag: 'picture',
+ src: 'images/image/banner.jpg',
+ alt: 'alt text goes here'
+}
+export const LazyPictureWithPlaceholder = TemplateLazyPicture.bind({})
+LazyPictureWithPlaceholder.args = {
+ tag: 'picture',
+ src: 'images/image/banner.jpg',
+ alt: 'alt text goes here',
+ placeholderSrc: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAG0lEQVQYV2M8c+bMfwZGRgbGs2fO/v/P8J8BAFA1CMsVPhxYAAAAAElFTkSuQmCC'
+}
diff --git a/src/atoms/image/Image.vue b/src/atoms/image/Image.vue
new file mode 100644
index 00000000..ad1067cf
--- /dev/null
+++ b/src/atoms/image/Image.vue
@@ -0,0 +1,10 @@
+
+
+
diff --git a/src/atoms/image/LazyImage.html b/src/atoms/image/LazyImage.html
new file mode 100644
index 00000000..4b0272a9
--- /dev/null
+++ b/src/atoms/image/LazyImage.html
@@ -0,0 +1,24 @@
+
+
diff --git a/src/atoms/image/LazyImage.js b/src/atoms/image/LazyImage.js
new file mode 100644
index 00000000..662d00fc
--- /dev/null
+++ b/src/atoms/image/LazyImage.js
@@ -0,0 +1,106 @@
+import getClass from '../../../utils/helpers/get-class.js'
+
+export default {
+ mixins: [getClass],
+ inheritAttrs: false,
+ props: {
+ /**
+ * To use another tag instead of `img`, e.g. `picture`
+ */
+ tag: {
+ type: String,
+ default: 'img',
+ validator: tag => tag === 'img' || tag === 'picture'
+ },
+ placeholderSrc: {
+ type: String,
+ default: null
+ },
+ imgLoadedClass: {
+ type: String,
+ default: 'image--loaded'
+ }
+ },
+ data () {
+ return {
+ observer: null,
+ intersected: false,
+ loaded: false,
+ config: {
+ base: {
+ image: [
+ 'block',
+ 'max-w-full',
+ 'opacity-0',
+ 'transition-opacity', 'duration-300', 'ease-linear'
+ ]
+ }
+ }
+ }
+ },
+ computed: {
+ usePicture () {
+ return this.tag === 'picture'
+ },
+ src () {
+ return this.intersected && this.$attrs.src
+ ? this.$attrs.src
+ : this.placeholderSrc
+ },
+ srcset () {
+ return this.intersected && this.$attrs.srcset
+ ? this.$attrs.srcset
+ : false
+ }
+ },
+ mounted () {
+ if ('IntersectionObserver' in window) {
+ this.observer = new IntersectionObserver(entries => {
+ const image = entries[0]
+ if (image.isIntersecting) {
+ this.intersected = true
+ this.observer.disconnect()
+ /**
+ * Triggered when image intersects the viewport
+ * @type {Event}
+ */
+ this.$emit('intersect')
+ }
+ }, this.intersectionOptions)
+ this.observer.observe(this.$el)
+ }
+ },
+ destroyed () {
+ if ('IntersectionObserver' in window) {
+ this.observer.disconnect()
+ }
+ },
+ methods: {
+ error (event) {
+ /**
+ * Triggered when failed to load an image
+ * @type {Event}
+ */
+ this.$emit('imageError', event)
+ },
+ load () {
+ if (this.$el.getAttribute('src') !== this.srcPlaceholder) {
+ this.loaded = true
+ /**
+ * Triggered when image defined in src is loaded
+ * @type {Event}
+ */
+ this.$emit('load')
+ }
+ },
+ loadPicture () {
+ if (
+ this.$refs.pictureImg &&
+ this.$refs.pictureImg.getAttribute('src') !== this.srcPlaceholder
+ ) {
+ this.loaded = true
+ this.$emit('load')
+ }
+ }
+ }
+}
diff --git a/src/atoms/image/LazyImage.spec.js b/src/atoms/image/LazyImage.spec.js
new file mode 100644
index 00000000..321d2322
--- /dev/null
+++ b/src/atoms/image/LazyImage.spec.js
@@ -0,0 +1,130 @@
+import { mount } from '@vue/test-utils'
+import merge from 'lodash.merge'
+import LazyImage from './LazyImage.vue'
+
+const slotContent = 'This is a placeholder'
+const factory = (overrideData = {}) => {
+ const defaultData = {
+ propsData: {
+ tag: 'img',
+ src: '/images/image/banner.jpg'
+ }
+ }
+
+ return mount(LazyImage, merge(defaultData, overrideData))
+}
+
+describe('atoms/LazyImage.vue', () => {
+ it('has default structure', () => {
+ const wrapper = factory()
+
+ expect(wrapper.element.tagName).toBe('IMG')
+ })
+
+ it('contains image src when intersected', async () => {
+ const wrapper = factory()
+
+ expect(wrapper.find('img').attributes().src).toBeUndefined()
+
+ await wrapper.setData({ intersected: true })
+
+ expect(wrapper.find('img').attributes().src).toBe(wrapper.vm.src)
+ })
+
+ it('@load event emitted when image loaded', async () => {
+ const wrapper = factory()
+
+ expect(wrapper.emitted('load')).toBeUndefined()
+
+ await wrapper.setData({ intersected: true })
+ wrapper.find('img').trigger('load')
+
+ expect(wrapper.emitted('load')).toBeDefined()
+ expect(wrapper.emitted('load').length).toBe(1)
+ })
+
+ it('placeholder slot data showed before standard image in picture tag', () => {
+ const wrapper = factory({
+ propsData: {
+ tag: 'picture',
+ placeholderSrc: 'data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAABCAQAAABN/Pf1AAAAC0lEQVR42mNkwAIAACoAAgu1Hc4AAAAASUVORK5CYII='
+ },
+ slots: {
+ placeholder: `${slotContent}`
+ }
+ })
+ wrapper.setData({ intersected: false })
+
+ expect(wrapper.find('.placeholder-slot').exists()).toBe(true)
+ expect(wrapper.find('.placeholder-slot').text()).toBe(slotContent)
+ })
+
+ it('default slot data showed if intersected', async () => {
+ const wrapper = factory({
+ propsData: {
+ tag: 'picture'
+ },
+ slots: {
+ default: `${slotContent}`
+ }
+ })
+
+ await wrapper.setData({ intersected: true })
+
+ expect(wrapper.find('.default-slot').exists()).toBe(true)
+ expect(wrapper.find('.default-slot').text()).toBe(slotContent)
+ })
+
+ it('@load event emitted when picture tag loaded', async () => {
+ const wrapper = factory({
+ propsData: {
+ tag: 'picture'
+ }
+ })
+
+ expect(wrapper.emitted('load')).toBeUndefined()
+
+ await wrapper.setData({ intersected: true })
+ wrapper.find('img').trigger('load')
+
+ expect(wrapper.emitted('load')).toBeDefined()
+ expect(wrapper.emitted('load').length).toBe(1)
+ })
+
+ it('@error event emitted when image is broken', () => {
+ const wrapper = factory()
+
+ expect(wrapper.emitted('image-error')).toBeUndefined()
+
+ wrapper.find('img').trigger('error')
+
+ expect(wrapper.emitted('imageError')).toBeDefined()
+ expect(wrapper.emitted('imageError').length).toBe(1)
+ })
+
+ it('@error event emitted when picture is broken', () => {
+ const wrapper = factory({
+ propsData: {
+ tag: 'picture'
+ }
+ })
+
+ expect(wrapper.emitted('image-error')).toBeUndefined()
+
+ wrapper.find('img').trigger('error')
+
+ expect(wrapper.emitted('imageError')).toBeDefined()
+ expect(wrapper.emitted('imageError').length).toBe(1)
+ })
+
+ it('should be generated with the `alt` passed as attributes', () => {
+ const wrapper = factory({
+ propsData: {
+ alt: 'Sample Image'
+ }
+ })
+
+ expect(wrapper.find('img').attributes().alt).toBeDefined()
+ expect(wrapper.find('img').attributes().alt).toEqual('Sample Image')
+ })
+})
diff --git a/src/atoms/image/LazyImage.vue b/src/atoms/image/LazyImage.vue
new file mode 100644
index 00000000..ecf56063
--- /dev/null
+++ b/src/atoms/image/LazyImage.vue
@@ -0,0 +1,15 @@
+
+
+
+
diff --git a/yarn.lock b/yarn.lock
index acdcf737..20faa516 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9932,6 +9932,11 @@ lodash.kebabcase@^4.1.1:
resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36"
integrity sha1-hImxyw0p/4gZXM7KRI/21swpXDY=
+lodash.merge@^4.6.2:
+ version "4.6.2"
+ resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
+ integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
+
lodash.sortby@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"