├── .editorconfig ├── .github └── FUNDING.yml ├── .gitignore ├── .prettierrc ├── README.md ├── babel.config.js ├── bili.config.js ├── circle.yml ├── jest.config.js ├── package.json ├── renovate.json ├── src ├── BulletListLoader.tsx ├── CodeLoader.tsx ├── ContentLoader.spec.js ├── ContentLoader.tsx ├── FacebookLoader.tsx ├── InstagramLoader.tsx ├── ListLoader.tsx ├── __snapshots__ │ └── index.spec.js.snap ├── index.spec.js ├── index.ts └── uid.ts ├── stories ├── Storybook.vue ├── index.js ├── poi.config.js └── storybook.js ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: egoist 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-content-loader 2 | 3 | [![NPM version](https://img.shields.io/npm/v/vue-content-loader.svg?style=flat)](https://npmjs.com/package/vue-content-loader) [![NPM downloads](https://img.shields.io/npm/dm/vue-content-loader.svg?style=flat)](https://npmjs.com/package/vue-content-loader) [![CircleCI](https://circleci.com/gh/egoist/vue-content-loader/tree/master.svg?style=shield)](https://circleci.com/gh/egoist/vue-content-loader/tree/master) 4 | 5 | SVG component to create placeholder loading, like Facebook cards loading. 6 | 7 | ![preview](https://user-images.githubusercontent.com/4838076/34308760-ec55df82-e735-11e7-843b-2e311fa7b7d0.gif) 8 | 9 | ## Features 10 | 11 | This is a Vue port for [react-content-loader](https://github.com/danilowoz/react-content-loader). 12 | 13 | - Completely customizable: you can change the colors, speed and sizes. 14 | - Create your own loading: use the [online tool](https://create-content-loader.now.sh/) to create your custom loader easily. 15 | - You can use it right now: there are a lot of presets already. 16 | - Performance: 17 | - Tree-shakable and highly optimized bundle. 18 | - Pure SVG, so it's works without any javascript, canvas, etc. 19 | - Pure functional components. 20 | 21 | ## Install 22 | 23 | ⚠️ **The latest version is compatible with Vue 3 only.** For Vue 2 & Nuxt 2, use `vue-content-loader@^0.2` instead. 24 | 25 | With npm: 26 | 27 | ```bash 28 | npm i vue-content-loader 29 | ``` 30 | 31 | Or with yarn: 32 | 33 | ```bash 34 | yarn add vue-content-loader 35 | ``` 36 | 37 | CDN: [UNPKG](https://unpkg.com/vue-content-loader/) | [jsDelivr](https://cdn.jsdelivr.net/npm/vue-content-loader/) (available as `window.contentLoaders`) 38 | 39 | ## Usage 40 | 41 | 👀👉 Demo: [CodeSandbox](https://codesandbox.io/s/vue-content-loader-igfyf?file=/src/App.vue) 42 | 43 | ```vue 44 | 47 | 48 | 57 | ``` 58 | 59 | ### Built-in loaders 60 | 61 | ```js 62 | import { 63 | ContentLoader, 64 | FacebookLoader, 65 | CodeLoader, 66 | BulletListLoader, 67 | InstagramLoader, 68 | ListLoader, 69 | } from 'vue-content-loader' 70 | ``` 71 | 72 | `ContentLoader` is a meta loader while other loaders are just higher-order components of it. By default `ContentLoader` only displays a simple rectangle, here's how you can use it to create custom loaders: 73 | 74 | ```vue 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | ``` 84 | 85 | This is also how [ListLoader](./src/ListLoader.js) is created. 86 | 87 | You can also use the [online tool](http://danilowoz.com/create-vue-content-loader/) to create shapes for your custom loader. 88 | 89 | ## API 90 | 91 | ### Props 92 | 93 | | Prop | Type | Default | Description | 94 | | ------------------- | -------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 95 | | width | number, string | | SVG width in pixels without unit | 96 | | height | number, string | | SVG height in pixels without unit | 97 | | viewBox | string | `'0 0 ${width ?? 400} ${height ?? 130}'` | See [SVG viewBox](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox) attribute | 98 | | preserveAspectRatio | string | `'xMidYMid meet'` | See [SVG preserveAspectRatio](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/preserveAspectRatio) attribute | 99 | | speed | number | `2` | Animation speed | 100 | | primaryColor | string | `'#f9f9f9'` | Background color | 101 | | secondaryColor | string | `'#ecebeb'` | Highlight color | 102 | | uniqueKey | string | `randomId()` | Unique ID, you need to make it consistent for SSR | 103 | | animate | boolean | `true` | | 104 | | baseUrl | string | empty string | Required if you're using `` in your ``. Defaults to an empty string. This prop is common used as: `` which will fill the SVG attribute with the relative path. Related [#14](https://github.com/egoist/vue-content-loader/issues/14). | 105 | | primaryOpacity | number | `1` | Background opacity (0 = transparent, 1 = opaque) used to solve an issue in Safari | 106 | | secondaryOpacity | number | `1` | Background opacity (0 = transparent, 1 = opaque) used to solve an issue in Safari | 107 | 108 | ## Examples 109 | 110 | ### Responsiveness 111 | 112 | To create a responsive loader that will follow its parent container width, use only the `viewBox` attribute to set the ratio: 113 | 114 | ```vue 115 | 116 | 117 | 118 | ``` 119 | 120 | To create a loader with fixed dimensions, use `width` and `height` attributes: 121 | 122 | ```vue 123 | 124 | 125 | 126 | ``` 127 | 128 | Note: the exact behavior might be different depending on the CSS you apply to SVG elements. 129 | 130 | ## Credits 131 | 132 | This is basically a Vue port for [react-content-loader](https://github.com/danilowoz/react-content-loader). 133 | 134 | [Thanks to @alidcastano for transferring the package name to me.](https://github.com/egoist/vue-content-loader/issues/1) 😘 135 | 136 | ## License 137 | 138 | MIT © [EGOIST](https://github.com/egoist) 139 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | // Jest tests are run in Node and it requires commonjs modules 7 | modules: process.env.NODE_ENV === 'test' ? 'commonjs' : false, 8 | loose: true, 9 | }, 10 | ], 11 | ], 12 | plugins: ['@vue/babel-plugin-jsx'], 13 | } 14 | -------------------------------------------------------------------------------- /bili.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('bili').Config} */ 2 | module.exports = { 3 | externals: ['vue'], 4 | input: 'src/index.ts', 5 | output: { 6 | format: ['cjs', 'es', 'umd', 'umd-min'], 7 | fileName: 'vue-content-loader.[format][min][ext]', 8 | moduleName: 'contentLoaders', 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | working_directory: ~/repo 5 | docker: 6 | - image: cimg/node:lts 7 | branches: 8 | ignore: 9 | - gh-pages # list of branches to ignore 10 | - /release\/.*/ # or ignore regexes 11 | steps: 12 | - checkout 13 | - restore_cache: 14 | key: dependency-cache-{{ checksum "yarn.lock" }} 15 | - run: 16 | name: install dependences 17 | command: yarn install 18 | - save_cache: 19 | key: dependency-cache-{{ checksum "yarn.lock" }} 20 | paths: 21 | - ./node_modules 22 | - run: 23 | name: test 24 | command: yarn run test 25 | - run: 26 | name: release 27 | command: npx semantic-release 28 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jsdom', 3 | transform: { 4 | '\\.[jt]sx?$': 'babel-jest', 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-content-loader", 3 | "version": "0.2.1", 4 | "description": "SVG component to create placeholder loading, like Facebook cards loading.", 5 | "repository": { 6 | "url": "egoist/vue-content-loader", 7 | "type": "git" 8 | }, 9 | "main": "dist/vue-content-loader.cjs.js", 10 | "module": "dist/vue-content-loader.es.js", 11 | "cdn": "dist/vue-content-loader.umd.min.js", 12 | "unpkg": "dist/vue-content-loader.umd.min.js", 13 | "jsdelivr": "dist/vue-content-loader.umd.min.js", 14 | "files": [ 15 | "dist" 16 | ], 17 | "types": "dist/index.d.ts", 18 | "sideEffects": false, 19 | "scripts": { 20 | "prepublishOnly": "npm run build", 21 | "test": "jest", 22 | "test:dev": "jest --watch", 23 | "build": "bili", 24 | "storybook": "poi -so --config stories/poi.config.js", 25 | "build:storybook": "poi --prod --config stories/poi.config.js", 26 | "format": "prettier -w ." 27 | }, 28 | "author": { 29 | "name": "EGOIST", 30 | "email": "0x142857@gmail.com" 31 | }, 32 | "license": "MIT", 33 | "devDependencies": { 34 | "@babel/core": "7.18.5", 35 | "@babel/preset-env": "7.18.2", 36 | "@types/poi": "12.5.5", 37 | "@vue/babel-plugin-jsx": "1.1.1", 38 | "@vue/compiler-sfc": "3.2.37", 39 | "@vue/test-utils": "2.0.1", 40 | "babel-jest": "27.5.1", 41 | "bili": "5.0.5", 42 | "jest": "27.5.1", 43 | "poi": "12.10.3", 44 | "prettier": "2.7.1", 45 | "regenerator-runtime": "0.13.9", 46 | "rollup-plugin-typescript2": "0.32.1", 47 | "typescript": "4.7.4", 48 | "vue": "3.2.37", 49 | "vue-loader": "17.0.0", 50 | "vue-router": "4.0.16" 51 | }, 52 | "peerDependencies": { 53 | "vue": "^3" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "packageRules": [ 4 | { 5 | "matchPackagePatterns": ["*"], 6 | "matchUpdateTypes": ["minor", "patch"], 7 | "groupName": "all non-major dependencies", 8 | "groupSlug": "all-minor-patch", 9 | "automerge": true, 10 | "labels": ["dependencies"] 11 | }, 12 | { 13 | "matchPackagePatterns": ["*"], 14 | "matchUpdateTypes": ["major"], 15 | "labels": ["dependencies", "breaking"] 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/BulletListLoader.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent } from 'vue' 2 | import ContentLoader from './ContentLoader' 3 | 4 | const BulletListLoader = defineComponent((props, { attrs }) => { 5 | return () => ( 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ) 17 | }) 18 | 19 | export default BulletListLoader 20 | -------------------------------------------------------------------------------- /src/CodeLoader.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent } from 'vue' 2 | import ContentLoader from './ContentLoader' 3 | 4 | const CodeLoader = defineComponent((props, { attrs }) => { 5 | return () => ( 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ) 21 | }) 22 | 23 | export default CodeLoader 24 | -------------------------------------------------------------------------------- /src/ContentLoader.spec.js: -------------------------------------------------------------------------------- 1 | import 'regenerator-runtime/runtime' 2 | import { mount } from '@vue/test-utils' 3 | 4 | import ContentLoader from './ContentLoader' 5 | 6 | describe('ContentLoader', () => { 7 | it('has default values for props', () => { 8 | const wrapper = mount(ContentLoader) 9 | 10 | expect(wrapper.vm.width).toBe(undefined) 11 | expect(wrapper.vm.height).toBe(undefined) 12 | expect(wrapper.vm.speed).toBe(2) 13 | expect(wrapper.vm.preserveAspectRatio).toBe('xMidYMid meet') 14 | expect(wrapper.vm.baseUrl).toBe('') 15 | expect(wrapper.vm.primaryColor).toBe('#f9f9f9') 16 | expect(wrapper.vm.secondaryColor).toBe('#ecebeb') 17 | expect(wrapper.vm.primaryOpacity).toBe(1) 18 | expect(wrapper.vm.secondaryOpacity).toBe(1) 19 | expect(wrapper.vm.uniqueKey).toBe(undefined) 20 | expect(wrapper.vm.animate).toBe(true) 21 | }) 22 | 23 | it('has viewbox, version and aspect ratio attributes on svg element', () => { 24 | const wrapper = mount(ContentLoader, { 25 | props: { 26 | width: 300, 27 | height: 200, 28 | preserveAspectRatio: 'xMaxYMid slice', 29 | }, 30 | }) 31 | 32 | expect(wrapper.find('svg').attributes()).toEqual({ 33 | width: '300', 34 | height: '200', 35 | preserveAspectRatio: 'xMaxYMid slice', 36 | version: '1.1', 37 | viewBox: '0 0 300 200', 38 | }) 39 | }) 40 | 41 | it('has viewbox, version and aspect ratio attributes on svg element', () => { 42 | const wrapper = mount(ContentLoader, { 43 | props: { 44 | width: 300, 45 | height: 200, 46 | preserveAspectRatio: 'xMaxYMid slice', 47 | }, 48 | }) 49 | 50 | expect(wrapper.find('svg').attributes()).toEqual({ 51 | width: '300', 52 | height: '200', 53 | preserveAspectRatio: 'xMaxYMid slice', 54 | version: '1.1', 55 | viewBox: '0 0 300 200', 56 | }) 57 | }) 58 | 59 | it('draws a rect filled by the gradient and clipped by the shapes', () => { 60 | const wrapper = mount(ContentLoader) 61 | 62 | expect(wrapper.find('rect').attributes()).toEqual({ 63 | style: `fill: url(#${wrapper.vm.idGradient});`, 64 | 'clip-path': `url(#${wrapper.vm.idClip})`, 65 | x: '0', 66 | y: '0', 67 | width: '100%', 68 | height: '100%', 69 | }) 70 | }) 71 | 72 | it('draws a clipPath with a unique ID', () => { 73 | const wrapper = mount(ContentLoader) 74 | 75 | expect(wrapper.find('defs clipPath').attributes()).toEqual({ 76 | id: wrapper.vm.idClip, 77 | }) 78 | }) 79 | 80 | it('draws a linear gradient with a unique ID', () => { 81 | const wrapper = mount(ContentLoader) 82 | 83 | expect(wrapper.find('defs linearGradient').attributes()).toEqual({ 84 | id: wrapper.vm.idGradient, 85 | }) 86 | }) 87 | 88 | it('draws a linear gradient with 3 stops', () => { 89 | const wrapper = mount(ContentLoader) 90 | const stops = wrapper.findAll('defs linearGradient stop') 91 | 92 | expect(stops.length).toBe(3) 93 | expect(stops[0].attributes()).toEqual({ 94 | offset: '0%', 95 | 'stop-color': '#f9f9f9', 96 | 'stop-opacity': '1', 97 | }) 98 | expect(stops[1].attributes()).toEqual({ 99 | offset: '50%', 100 | 'stop-color': '#ecebeb', 101 | 'stop-opacity': '1', 102 | }) 103 | expect(stops[2].attributes()).toEqual({ 104 | offset: '100%', 105 | 'stop-color': '#f9f9f9', 106 | 'stop-opacity': '1', 107 | }) 108 | }) 109 | 110 | it('animates the gradient by default using given speed', () => { 111 | const wrapper = mount(ContentLoader) 112 | const animations = wrapper.findAll('defs linearGradient animate') 113 | 114 | expect(animations.length).toBe(3) 115 | expect(animations[0].attributes()).toEqual({ 116 | attributeName: 'offset', 117 | values: '-2; 1', 118 | dur: '2s', 119 | repeatCount: 'indefinite', 120 | }) 121 | expect(animations[1].attributes()).toEqual({ 122 | attributeName: 'offset', 123 | values: '-1.5; 1.5', 124 | dur: '2s', 125 | repeatCount: 'indefinite', 126 | }) 127 | expect(animations[2].attributes()).toEqual({ 128 | attributeName: 'offset', 129 | values: '-1; 2', 130 | dur: '2s', 131 | repeatCount: 'indefinite', 132 | }) 133 | }) 134 | 135 | it('does not animate if animate prop is false', () => { 136 | const wrapper = mount(ContentLoader, { 137 | props: { 138 | animate: false, 139 | }, 140 | }) 141 | 142 | expect(wrapper.findAll('defs linearGradient animate').length).toBe(0) 143 | }) 144 | 145 | it('has a default element to clip with', () => { 146 | const wrapper = mount(ContentLoader) 147 | 148 | expect(wrapper.find('defs clipPath rect').attributes()).toEqual({ 149 | x: '0', 150 | y: '0', 151 | rx: '5', 152 | ry: '5', 153 | width: '100%', 154 | height: '100%', 155 | }) 156 | }) 157 | 158 | it('outputs the default slot within the clipPath', () => { 159 | const wrapper = mount(ContentLoader, { 160 | slots: { 161 | default: '', 162 | }, 163 | }) 164 | 165 | expect(wrapper.find('defs clipPath circle').html()).toEqual( 166 | '' 167 | ) 168 | }) 169 | 170 | it('use the baseUrl to link the gradient & clip path', () => { 171 | const wrapper = mount(ContentLoader, { 172 | props: { 173 | baseUrl: '/path', 174 | }, 175 | }) 176 | 177 | expect(wrapper.find('rect').attributes()).toMatchObject({ 178 | style: `fill: url(/path#${wrapper.vm.idGradient});`, 179 | 'clip-path': `url(/path#${wrapper.vm.idClip})`, 180 | }) 181 | }) 182 | 183 | it('use the uniqueKey to generate static IDs', () => { 184 | const wrapper = mount(ContentLoader, { 185 | props: { 186 | uniqueKey: 'unique', 187 | }, 188 | }) 189 | 190 | expect(wrapper.vm.idClip).toEqual('unique-idClip') 191 | expect(wrapper.vm.idGradient).toEqual('unique-idGradient') 192 | }) 193 | 194 | it('apply extra attributes on the root element', () => { 195 | const wrapper = mount(ContentLoader, { 196 | props: { 197 | class: 'loader', 198 | id: 'loader', 199 | }, 200 | }) 201 | 202 | expect(wrapper.find('svg').classes()).toEqual(['loader']) 203 | expect(wrapper.find('svg').attributes()).toMatchObject({ id: 'loader' }) 204 | }) 205 | 206 | it('updates the computedViewBox when props change', async () => { 207 | const wrapper = mount(ContentLoader, { 208 | props: { 209 | viewBox: '0 0 100 100', 210 | }, 211 | }) 212 | 213 | expect(wrapper.find('svg').attributes('viewBox')).toBe('0 0 100 100') 214 | 215 | await wrapper.setProps({ viewBox: '0 0 200 200' }) 216 | expect(wrapper.find('svg').attributes('viewBox')).toBe('0 0 200 200') 217 | 218 | await wrapper.setProps({ viewBox: null, width: 50, height: 100 }) 219 | expect(wrapper.find('svg').attributes('viewBox')).toBe('0 0 50 100') 220 | }) 221 | }) 222 | -------------------------------------------------------------------------------- /src/ContentLoader.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, computed } from 'vue' 2 | import uid from './uid' 3 | 4 | export default defineComponent({ 5 | name: 'ContentLoader', 6 | 7 | props: { 8 | width: { 9 | type: [Number, String], 10 | }, 11 | height: { 12 | type: [Number, String], 13 | }, 14 | viewBox: { 15 | type: String, 16 | }, 17 | preserveAspectRatio: { 18 | type: String, 19 | default: 'xMidYMid meet', 20 | }, 21 | speed: { 22 | type: Number, 23 | default: 2, 24 | }, 25 | baseUrl: { 26 | type: String, 27 | default: '', 28 | }, 29 | primaryColor: { 30 | type: String, 31 | default: '#f9f9f9', 32 | }, 33 | secondaryColor: { 34 | type: String, 35 | default: '#ecebeb', 36 | }, 37 | primaryOpacity: { 38 | type: Number, 39 | default: 1, 40 | }, 41 | secondaryOpacity: { 42 | type: Number, 43 | default: 1, 44 | }, 45 | uniqueKey: { 46 | type: String, 47 | }, 48 | animate: { 49 | type: Boolean, 50 | default: true, 51 | }, 52 | }, 53 | 54 | setup(props) { 55 | const idClip = computed(() => 56 | props.uniqueKey ? `${props.uniqueKey}-idClip` : uid() 57 | ) 58 | const idGradient = computed(() => 59 | props.uniqueKey ? `${props.uniqueKey}-idGradient` : uid() 60 | ) 61 | const width = computed(() => props.width ?? 400) 62 | const height = computed(() => props.height ?? 130) 63 | const computedViewBox = computed( 64 | () => props.viewBox ?? `0 0 ${width.value} ${height.value}` 65 | ) 66 | 67 | return { 68 | idClip, 69 | idGradient, 70 | computedViewBox, 71 | } 72 | }, 73 | 74 | render() { 75 | return ( 76 | 83 | 91 | 92 | 93 | 94 | {this.$slots.default ? ( 95 | this.$slots.default() 96 | ) : ( 97 | 98 | )} 99 | 100 | 101 | 102 | 107 | {this.animate ? ( 108 | 114 | ) : null} 115 | 116 | 121 | {this.animate ? ( 122 | 128 | ) : null} 129 | 130 | 135 | {this.animate ? ( 136 | 142 | ) : null} 143 | 144 | 145 | 146 | 147 | ) 148 | }, 149 | }) 150 | -------------------------------------------------------------------------------- /src/FacebookLoader.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent } from 'vue' 2 | import ContentLoader from './ContentLoader' 3 | 4 | const FacebookLoader = defineComponent((props, { attrs }) => { 5 | return () => ( 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ) 15 | }) 16 | 17 | export default FacebookLoader 18 | -------------------------------------------------------------------------------- /src/InstagramLoader.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent } from 'vue' 2 | import ContentLoader from './ContentLoader' 3 | 4 | const InstagramLoader = defineComponent((props, { attrs }) => { 5 | return () => ( 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | ) 14 | }) 15 | 16 | export default InstagramLoader 17 | -------------------------------------------------------------------------------- /src/ListLoader.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent } from 'vue' 2 | import ContentLoader from './ContentLoader' 3 | 4 | const ListLoader = defineComponent((props, { attrs }) => { 5 | return () => ( 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ) 15 | }) 16 | 17 | export default ListLoader 18 | -------------------------------------------------------------------------------- /src/__snapshots__/index.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`BulletListLoader renders its shapes 1`] = ` 4 | Array [ 5 | "", 6 | "", 7 | "", 8 | "", 9 | "", 10 | "", 11 | "", 12 | "", 13 | ] 14 | `; 15 | 16 | exports[`CodeLoader renders its shapes 1`] = ` 17 | Array [ 18 | "", 19 | "", 20 | "", 21 | "", 22 | "", 23 | "", 24 | "", 25 | "", 26 | "", 27 | ] 28 | `; 29 | 30 | exports[`FacebookLoader renders its shapes 1`] = ` 31 | Array [ 32 | "", 33 | "", 34 | "", 35 | "", 36 | "", 37 | "", 38 | ] 39 | `; 40 | 41 | exports[`InstagramLoader renders its shapes 1`] = ` 42 | Array [ 43 | "", 44 | "", 45 | "", 46 | "", 47 | ] 48 | `; 49 | 50 | exports[`ListLoader renders its shapes 1`] = ` 51 | Array [ 52 | "", 53 | "", 54 | "", 55 | "", 56 | "", 57 | "", 58 | ] 59 | `; 60 | -------------------------------------------------------------------------------- /src/index.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | 3 | import BulletListLoader from './BulletListLoader' 4 | import CodeLoader from './CodeLoader' 5 | import FacebookLoader from './FacebookLoader' 6 | import ListLoader from './ListLoader' 7 | import InstagramLoader from './InstagramLoader' 8 | 9 | describe.each([ 10 | ['BulletListLoader', BulletListLoader], 11 | ['CodeLoader', CodeLoader], 12 | ['FacebookLoader', FacebookLoader], 13 | ['ListLoader', ListLoader], 14 | ['InstagramLoader', InstagramLoader], 15 | ])('%s', (name, component) => { 16 | it('renders its shapes', () => { 17 | const wrapper = mount(component) 18 | 19 | expect( 20 | wrapper.findAll('clipPath > *').map((c) => c.html()) 21 | ).toMatchSnapshot() 22 | }) 23 | 24 | it('forwards attributes to ContentLoader', () => { 25 | const wrapper = mount(component, { 26 | props: { 27 | id: 'loader', 28 | class: 'loader', 29 | }, 30 | }) 31 | 32 | expect(wrapper.attributes()).toMatchObject({ 33 | id: 'loader', 34 | class: 'loader', 35 | }) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ContentLoader } from './ContentLoader' 2 | export { default as BulletListLoader } from './BulletListLoader' 3 | export { default as CodeLoader } from './CodeLoader' 4 | export { default as FacebookLoader } from './FacebookLoader' 5 | export { default as ListLoader } from './ListLoader' 6 | export { default as InstagramLoader } from './InstagramLoader' 7 | -------------------------------------------------------------------------------- /src/uid.ts: -------------------------------------------------------------------------------- 1 | export default () => Math.random().toString(36).substring(2) 2 | -------------------------------------------------------------------------------- /stories/Storybook.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 78 | 79 | 84 | 85 | 124 | -------------------------------------------------------------------------------- /stories/index.js: -------------------------------------------------------------------------------- 1 | import { createStorybook } from './storybook' 2 | import { 3 | ContentLoader, 4 | FacebookLoader, 5 | CodeLoader, 6 | BulletListLoader, 7 | InstagramLoader, 8 | ListLoader, 9 | } from '../src' 10 | 11 | const Container = { 12 | functional: true, 13 | render() { 14 | return ( 15 |
16 | {this.$slots.default()} 17 |
18 | ) 19 | }, 20 | } 21 | 22 | const MyLoader = { 23 | render() { 24 | return ( 25 | 31 | 32 | 33 | 34 | 35 | ) 36 | }, 37 | } 38 | 39 | const storybook = createStorybook({ 40 | title: 'vue-content-loader', 41 | }) 42 | 43 | const section = storybook.addSection({ 44 | title: 'ContentLoader', 45 | }) 46 | 47 | section 48 | .addStory({ 49 | title: 'facebook style', 50 | component: { 51 | render() { 52 | return ( 53 | 54 | 55 | 56 | ) 57 | }, 58 | }, 59 | }) 60 | .addStory({ 61 | title: 'instagram style', 62 | component: { 63 | render() { 64 | return ( 65 | 66 | 67 | 68 | ) 69 | }, 70 | }, 71 | }) 72 | .addStory({ 73 | title: 'code style', 74 | component: { 75 | render() { 76 | return ( 77 | 78 | 79 | 80 | ) 81 | }, 82 | }, 83 | }) 84 | .addStory({ 85 | title: 'list style', 86 | component: { 87 | render() { 88 | return ( 89 | 90 | 91 | 92 | ) 93 | }, 94 | }, 95 | }) 96 | .addStory({ 97 | title: 'bullet list style', 98 | component: { 99 | render() { 100 | return ( 101 | 102 | 103 | 104 | ) 105 | }, 106 | }, 107 | }) 108 | .addStory({ 109 | title: 'custom style', 110 | component: { 111 | render() { 112 | return ( 113 | 114 | 115 | 116 | ) 117 | }, 118 | }, 119 | }) 120 | .addStory({ 121 | title: 'className', 122 | component: { 123 | render() { 124 | return ( 125 | 126 | 127 | 128 | ) 129 | }, 130 | }, 131 | }) 132 | .addStory({ 133 | title: 'width and height', 134 | component: { 135 | render() { 136 | return ( 137 | 138 | 139 | 140 | ) 141 | }, 142 | }, 143 | }) 144 | .addStory({ 145 | title: 'custom viewBox', 146 | component: { 147 | render() { 148 | return ( 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | ) 160 | }, 161 | }, 162 | }) 163 | .addStory({ 164 | title: 'unique-key: for SSR', 165 | component: { 166 | render() { 167 | return ( 168 | 169 | 170 | 171 | ) 172 | }, 173 | }, 174 | }) 175 | .addStory({ 176 | title: 'no animation', 177 | component: { 178 | render() { 179 | return ( 180 | 181 | 182 | 183 | ) 184 | }, 185 | }, 186 | }) 187 | 188 | storybook.open('#app') 189 | -------------------------------------------------------------------------------- /stories/poi.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const path = require('path') 3 | 4 | /** @type {import('poi').Config} */ 5 | const config = { 6 | entry: path.join(__dirname, 'index.js'), 7 | output: { 8 | dir: path.join(__dirname, 'dist'), 9 | }, 10 | } 11 | 12 | module.exports = config 13 | -------------------------------------------------------------------------------- /stories/storybook.js: -------------------------------------------------------------------------------- 1 | import { createApp, h } from 'vue' 2 | import { createRouter, createWebHashHistory, RouterView } from 'vue-router' 3 | import StorybookRoot from './Storybook.vue' 4 | 5 | export const createStorybook = (opts) => new Storybook(opts) 6 | 7 | class Storybook { 8 | constructor({ title }) { 9 | this.sections = [] 10 | this.site = { 11 | title, 12 | } 13 | } 14 | 15 | open(target) { 16 | const router = createRouter({ 17 | history: createWebHashHistory(), 18 | routes: [ 19 | { 20 | path: '/:pathMatch(.*)*', 21 | component: StorybookRoot, 22 | props: { 23 | sections: this.sections.map((section) => section.toObject()), 24 | site: this.site, 25 | }, 26 | }, 27 | ], 28 | }) 29 | 30 | const vm = createApp({ 31 | render: () => h(RouterView), 32 | }) 33 | .use(router) 34 | .mount(target) 35 | 36 | return this 37 | } 38 | 39 | addSection(opts) { 40 | const section = new Section(opts) 41 | this.sections.push(section) 42 | return section 43 | } 44 | } 45 | 46 | class Section { 47 | constructor({ title }) { 48 | this.title = title 49 | this.stories = [] 50 | } 51 | 52 | addStory(story) { 53 | this.stories.push(story) 54 | return this 55 | } 56 | 57 | toObject() { 58 | return { 59 | title: this.title, 60 | stories: this.stories, 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*.ts", "src/**/*.tsx"], 3 | "exclude": ["node_modules", "dist"], 4 | "compilerOptions": { 5 | "rootDir": "src", 6 | "target": "es5", 7 | "module": "esnext", 8 | "jsx": "preserve", 9 | "moduleResolution": "node", 10 | "declaration": true, 11 | "sourceMap": true, 12 | "strict": true, 13 | "noImplicitAny": true, 14 | "esModuleInterop": true, 15 | "forceConsistentCasingInFileNames": true 16 | } 17 | } 18 | --------------------------------------------------------------------------------