├── .github ├── logo.png └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── babel.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── create-define-mixin.js ├── subslot.js └── utils │ ├── arr-remove.js │ ├── emit.js │ └── filter-vnodes.js ├── test ├── __snapshots__ │ ├── filter-attr.test.js.snap │ └── mixin.test.js.snap ├── filter-attr.test.js ├── fixtures │ ├── CardFooter.vue │ └── CardHeader.vue └── mixin.test.js └── xo.config.js /.github/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privatenumber/vue-subslot/18069e5f187f174845f18fed46870047231d3a5a/.github/logo.png -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [master, next, next-major, beta, alpha] 6 | 7 | jobs: 8 | release: 9 | name: Release 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 10 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: 14.x 20 | - name: Install dependencies 21 | run: npm ci 22 | - name: Lint 23 | run: npm run lint 24 | - name: Test 25 | run: npm run test --if-present 26 | - name: Build 27 | run: npm run build --if-present 28 | - name: Release 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 32 | run: npx semantic-release 33 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [develop] 6 | pull_request: 7 | branches: [develop, master, next, next-major, beta, alpha] 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ubuntu-latest 13 | timeout-minutes: 10 14 | 15 | strategy: 16 | matrix: 17 | node-version: [10.x, 14.x] 18 | 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v2 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - name: Install dependencies 27 | run: npm ci 28 | - name: Lint 29 | run: npm run lint 30 | - name: Test 31 | run: npm run test --if-present 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | .DS_Store 3 | 4 | # Logs 5 | logs 6 | *.log 7 | 8 | # Node dependency directory 9 | node_modules 10 | 11 | # Output of 'npm pack' 12 | *.tgz 13 | 14 | # dotenv environment variables file 15 | .env 16 | .env.test 17 | 18 | # Distribution 19 | dist 20 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v12.18.4 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Hiroki Osame 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | 4 |

5 | 6 | 7 | 8 |
9 |

10 | 11 | Pick out specific elements from the component ``. 12 | 13 | ```html 14 | 19 | ``` 20 | 21 | ## 🚀 Install 22 | ```sh 23 | npm i vue-subslot 24 | ``` 25 | 26 | ## 🙋‍♂️ Why? 27 | - **🔥 Cleaner Slot API** Give your users a cleaner and more readable API! 28 | - **🧠 Full Slot control** Filter out and limit unwanted content from slots! 29 | - **🐥 Tiny** `1.04 KB` minzipped! 30 | 31 | ## 👨🏻‍🏫 Examples 32 | Have you ever developed a parent-child component set, and wanted to allow users to pass in the child-component without specifiying a slot but still have the same level of control as named-slots? With Subslot, you can! 33 | 34 | 35 |
36 | 37 | Demo 1: Inline filter attributes 38 | 39 | 40 | 41 |
42 | 43 | Imagine being able to offer the following API with parent-child components _Card_ and _CardHeader_. 44 | 45 | ```html 46 | 47 | 48 | 49 | My special card 50 | 51 | 52 | My card content 53 | 54 | ``` 55 | 56 | Using Subslot, this is all the code you need to make this possible. This is what _Card.vue_ looks like. 57 | 58 | ```html 59 | 72 | 73 | 86 | ``` 87 | 88 |
89 | 90 | 91 |
92 | 93 | Demo 2: Named Subslots 94 | 95 | 96 | 97 |
98 | 99 | Alternatively to using inline filter attributes, you can define subslots on the component. With this approach, you can access subslots like you would normal slots but via `$subslots`. This is what _Card.vue_ would look like. 100 | 101 | ```html 102 | 117 | 118 | 145 | ``` 146 | 147 |
148 | 149 | ## 📖 API 150 | 151 | #### Filter by element tag 152 | As a string, it filters the vnodes by tag (as opposed to component) 153 | 154 | ```html 155 | 156 | ``` 157 | 158 | Filter the vnodes with tag `child-component` 159 | 160 | ```html 161 | 162 | ``` 163 | 164 | #### To match a specific component 165 | Use the `@` prefix to use the component from the `components` hash 166 | 167 | ```html 168 | 169 | ``` 170 | 171 | Or, pass in the direct Component reference 172 | 173 | ```html 174 | 175 | ``` 176 | 177 | #### To match multiple elements 178 | Pass in an array 179 | 180 | ```html 181 | 182 | ``` 183 | 184 | #### To match any element 185 | Use the asterisk to match any element (incl. components). This can be used to filter out text/white-space. 186 | 187 | ```html 188 | 189 | ``` 190 | 191 | #### Offset the number of returned elements 192 | ```html 193 | 197 | ``` 198 | 199 | #### Limit the number of returned elements 200 | ```html 201 | 206 | ``` 207 | 208 | #### Inverse the filter 209 | Set the `not` boolean to inverse the filter and get everything that _doesn't_ match. 210 | 211 | ```html 212 | 213 | ``` 214 | 215 | #### Text only 216 | Inverse the element match-all to match only text nodes. 217 | 218 | ```html 219 | 220 | ``` 221 | 222 | #### Slot fallback 223 | Like normal slots, what you pass into the slot of `subslot` will be the fallback content of that `subslot`. 224 | 225 | ```html 226 | 227 | 228 | 229 | ``` 230 | 231 | ## 📬 Events 232 | - `@no-match`: Emitted when there are no matching vnodes 233 | 234 | 235 | ## ⚡ Advanced usage 236 | 237 | ### Pass in vnodes from a difference source 238 | ```html 239 | 243 | ``` 244 | 245 | ## 💁‍♀️ FAQ 246 | 247 | ### Will this work for functional components passed into the slot? 248 | 249 | Unfortunately not due to how functional components are implemented in Vue.js. 250 | 251 | Functional components are stateless and are immediately invoked as a function that outputs vNodes. The outputted vNodes are passed into the slot in place of the functional component. Because Subslot doesn't actually receive the functional component, it's impossible to detect them. 252 | 253 | 254 | ## 👨‍👩‍👧 Related 255 | - [vue-proxi](https://github.com/privatenumber/vue-proxi) - 💠 Tiny proxy component 256 | - [vue-vnode-syringe](https://github.com/privatenumber/vue-vnode-syringe) - 🧬 Add attributes and event-listeners to `` content 💉 257 | - [vue-pseudo-window](https://github.com/privatenumber/vue-pseudo-window) - 🖼 Declaratively interface window/document in your Vue template 258 | - [vue-v](https://github.com/privatenumber/vue-v) - render vNodes via component template 259 | - [vue-frag](https://github.com/privatenumber/vue-frag) - 🤲 Directive to return multiple root elements 260 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | loose: true, 7 | targets: 'ie 11', 8 | }, 9 | ], 10 | ], 11 | env: { 12 | test: { 13 | presets: [ 14 | [ 15 | '@babel/preset-env', 16 | { 17 | useBuiltIns: 'usage', 18 | corejs: 3, 19 | }, 20 | ], 21 | ], 22 | }, 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleNameMapper: { 3 | 'vue-subslot': '/src/subslot', 4 | }, 5 | transform: { 6 | '\\.vue$': 'vue-jest', 7 | '\\.js$': 'babel-jest', 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-subslot", 3 | "version": "0.0.0-semantic-release", 4 | "description": "💍 Pick 'n choose what you want from a slot passed into your Vue component", 5 | "keywords": [ 6 | "vue", 7 | "subslot", 8 | "filter", 9 | "pick", 10 | "slot", 11 | "vnode", 12 | "component", 13 | "util" 14 | ], 15 | "license": "MIT", 16 | "repository": "privatenumber/vue-subslot", 17 | "funding": "https://github.com/privatenumber/vue-subslot?sponsor=1", 18 | "author": { 19 | "name": "Hiroki Osame", 20 | "email": "hiroki.osame@gmail.com" 21 | }, 22 | "files": [ 23 | "dist" 24 | ], 25 | "main": "dist/subslot.js", 26 | "module": "dist/subslot.esm.js", 27 | "scripts": { 28 | "build": "rollup -c --environment NODE_ENV:production", 29 | "dev-build": "rollup -cw", 30 | "dev": "jest --watchAll", 31 | "test": "jest", 32 | "lint": "xo" 33 | }, 34 | "husky": { 35 | "hooks": { 36 | "pre-commit": "lint-staged" 37 | } 38 | }, 39 | "lint-staged": { 40 | "*.js": [ 41 | "xo", 42 | "jest --bail --findRelatedTests" 43 | ] 44 | }, 45 | "devDependencies": { 46 | "@babel/core": "^7.12.3", 47 | "@babel/preset-env": "^7.12.1", 48 | "@vue/test-utils": "^1.1.1", 49 | "babel-jest": "^26.6.2", 50 | "core-js": "^3.6.5", 51 | "eslint-plugin-vue": "^7.1.0", 52 | "husky": "^4.3.0", 53 | "jest": "^26.6.2", 54 | "lint-staged": "^10.5.1", 55 | "rollup": "^2.33.0", 56 | "rollup-plugin-babel": "^4.4.0", 57 | "rollup-plugin-filesize": "^9.0.2", 58 | "rollup-plugin-terser": "^7.0.2", 59 | "vue": "^2.6.12", 60 | "vue-jest": "^4.0.0-rc.0", 61 | "vue-template-compiler": "^2.6.12", 62 | "xo": "^0.34.2" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import {terser} from 'rollup-plugin-terser'; 3 | import filesize from 'rollup-plugin-filesize'; 4 | 5 | const isProd = process.env.NODE_ENV === 'production'; 6 | 7 | const rollupConfig = { 8 | input: 'src/subslot.js', 9 | plugins: [ 10 | babel(), 11 | isProd && terser(), 12 | isProd && filesize(), 13 | ], 14 | output: [ 15 | { 16 | format: 'umd', 17 | file: 'dist/subslot.js', 18 | name: 'Subslot', 19 | exports: 'default', 20 | }, 21 | { 22 | format: 'es', 23 | file: 'dist/subslot.esm.js', 24 | }, 25 | ], 26 | }; 27 | 28 | export default rollupConfig; 29 | -------------------------------------------------------------------------------- /src/create-define-mixin.js: -------------------------------------------------------------------------------- 1 | import arrRemove from './utils/arr-remove'; 2 | import filterVnodes from './utils/filter-vnodes'; 3 | 4 | const arrayPtrn = /(.+)\[(\d?)(?::(\d+))?]$/; 5 | const parseFilterString = stringFilter => { 6 | let not = false; 7 | let element; 8 | let offset = 0; 9 | let limit; 10 | 11 | if (arrayPtrn.test(stringFilter)) { 12 | stringFilter = stringFilter.replace(arrayPtrn, (_, _element, _offset, _limit) => { 13 | element = _element; 14 | if (_offset) { 15 | offset = _offset; 16 | } 17 | 18 | if (_limit) { 19 | limit = _limit; 20 | } 21 | 22 | return ''; 23 | }); 24 | } else { 25 | const filterSplit = stringFilter.split(':'); 26 | element = filterSplit[0]; 27 | limit = filterSplit[1]; 28 | } 29 | 30 | if (element[0] === '!') { 31 | not = true; 32 | element = element.slice(1); 33 | } 34 | 35 | element = element.split(','); 36 | 37 | return { 38 | element, offset, limit, not, 39 | }; 40 | }; 41 | 42 | const genSubSlots = ({sslotDef, vnodes, vm}) => { 43 | if (!vnodes) { 44 | return {}; 45 | } 46 | 47 | const slots = { 48 | default: vnodes.slice(0), 49 | // _original: vnodes, 50 | }; 51 | 52 | Object.entries(sslotDef).forEach(([name, def]) => { 53 | const filtered = filterVnodes({ 54 | filter: typeof def === 'string' ? parseFilterString(def) : def, 55 | vnodes, 56 | vm, 57 | }); 58 | 59 | filtered.forEach(vn => arrRemove(slots.default, vn)); 60 | 61 | if (filtered.length > 0) { 62 | slots[name] = filtered; 63 | } 64 | }); 65 | 66 | return slots; 67 | }; 68 | 69 | export default function createDefineMixin(sslotDef) { 70 | function generateSubslots() { 71 | this.$subslots = genSubSlots({ 72 | sslotDef, 73 | vnodes: this.$slots.default, 74 | vm: this, 75 | }); 76 | } 77 | 78 | return { 79 | created: generateSubslots, 80 | beforeUpdate: generateSubslots, 81 | }; 82 | } 83 | -------------------------------------------------------------------------------- /src/subslot.js: -------------------------------------------------------------------------------- 1 | import emit from './utils/emit'; 2 | import filterVnodes from './utils/filter-vnodes'; 3 | import createDefineMixin from './create-define-mixin'; 4 | 5 | const validInt = value => !Number.isNaN(Number.parseInt(value, 10)); 6 | 7 | const Subslot = { 8 | functional: true, 9 | props: { 10 | not: { 11 | type: Boolean, 12 | }, 13 | element: { 14 | type: [Object, Array, String], 15 | }, 16 | offset: { 17 | type: [String, Number], 18 | default: 0, 19 | validator: validInt, 20 | }, 21 | limit: { 22 | type: [String, Number], 23 | validator: validInt, 24 | }, 25 | vnodes: { 26 | type: null, 27 | }, 28 | name: { 29 | type: String, 30 | default: 'default', 31 | }, 32 | }, 33 | 34 | render(h, ctx) { 35 | const {props, parent} = ctx; 36 | 37 | let vnodes; 38 | 39 | // Detect definition 40 | vnodes = parent.$subslots ? parent.$subslots[props.name] : props.vnodes || parent.$slots.default || []; 41 | 42 | vnodes = filterVnodes({ 43 | vnodes, 44 | filter: props, 45 | vm: parent, 46 | }); 47 | 48 | if (!vnodes || vnodes.length === 0) { 49 | emit(ctx, 'no-match'); 50 | return ctx.slots().default; 51 | } 52 | 53 | return vnodes; 54 | }, 55 | 56 | /* Static method for mixin */ 57 | define: createDefineMixin, 58 | }; 59 | 60 | export default Subslot; 61 | -------------------------------------------------------------------------------- /src/utils/arr-remove.js: -------------------------------------------------------------------------------- 1 | const arrayRemove = (array, element) => array.splice(array.indexOf(element), 1); 2 | 3 | export default arrayRemove; 4 | -------------------------------------------------------------------------------- /src/utils/emit.js: -------------------------------------------------------------------------------- 1 | const emit = function (ctx, eventName /* ...args */) { 2 | const eventHandler = ctx.listeners[eventName]; 3 | if (typeof eventHandler === 'function') { 4 | const args = Array.from(arguments).slice(2); 5 | eventHandler.apply(this, args); 6 | } 7 | }; 8 | 9 | export default emit; 10 | -------------------------------------------------------------------------------- /src/utils/filter-vnodes.js: -------------------------------------------------------------------------------- 1 | const getWhitelist = ({vm, filter}) => { 2 | let matchAll = false; 3 | const components = []; 4 | const tags = []; 5 | const elements = Array.isArray(filter.element) ? filter.element : [filter.element]; 6 | 7 | elements.forEach(element => { 8 | if (typeof element === 'string') { 9 | if (element === '*') { 10 | matchAll = true; 11 | } else if (element[0] === '@') { 12 | const component = vm.$options.components[element.slice(1)]; 13 | if (component) { 14 | components.push(component); 15 | } 16 | } else { 17 | tags.push(element); 18 | } 19 | } else { 20 | components.push(element); 21 | } 22 | }); 23 | 24 | return {matchAll, components, tags}; 25 | }; 26 | 27 | const filterVnodes = ({vnodes, filter, vm}) => { 28 | if (filter.element) { 29 | const {matchAll, components, tags} = getWhitelist({vm, filter}); 30 | 31 | vnodes = vnodes.filter(vnode => { 32 | let hasMatch; 33 | const {tag} = vnode; 34 | 35 | if (matchAll) { 36 | hasMatch = tag; 37 | } else if (tag) { 38 | const isComponent = (vnode.componentOptions && vnode.componentOptions.Ctor.extendOptions); 39 | hasMatch = isComponent ? components.includes(isComponent) : tags.includes(tag); 40 | } 41 | 42 | return filter.not ? !hasMatch : hasMatch; 43 | }); 44 | } 45 | 46 | if (filter.offset) { 47 | vnodes = vnodes.slice(filter.offset); 48 | } 49 | 50 | if (filter.limit) { 51 | vnodes = vnodes.slice(0, filter.limit); 52 | } 53 | 54 | return vnodes; 55 | }; 56 | 57 | export default filterVnodes; 58 | -------------------------------------------------------------------------------- /test/__snapshots__/filter-attr.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Subslot Should enforce offset and limit without element 1`] = ` 4 | "
5 |
6 | Header 4 7 |
8 |
9 | Header 5 10 |
11 |
12 | Header 6 13 |
14 |
" 15 | `; 16 | 17 | exports[`Subslot Should enforce offset of 3 1`] = ` 18 | "
19 |
20 | Header 4 21 |
22 |
23 | Header 5 24 |
25 |
26 | Header 6 27 |
28 |
" 29 | `; 30 | 31 | exports[`Subslot Should only be 3 CardHeaders 1`] = ` 32 | "
33 |
34 | Header 35 |
36 |
37 | Header 38 |
39 |
40 | Header 41 |
42 |
" 43 | `; 44 | 45 | exports[`Subslot Should render all 1`] = ` 46 | "
47 |
Should render
Should render 48 |
" 49 | `; 50 | 51 | exports[`Subslot Should support element as a direct reference 1`] = ` 52 | "
53 |
54 |
55 | Header 56 |
57 |
58 |
59 | 60 | Content 61 |
62 |
" 63 | `; 64 | 65 | exports[`Subslot Should support filter attributes 1`] = ` 66 | "
67 |
68 |
69 | Header 70 |
71 |
72 |
73 | 74 | Content 75 |
76 |
" 77 | `; 78 | 79 | exports[`Subslot Should support tag 1`] = `"
"`; 80 | 81 | exports[`Subslot Should support tag 2`] = ` 82 | "
83 | Should render 84 |
" 85 | `; 86 | 87 | exports[`Subslot Should support wildcard 1`] = `"
Should render
"`; 88 | -------------------------------------------------------------------------------- /test/__snapshots__/mixin.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`$subslots support Empty slot 1`] = ` 4 | "
5 | 6 |
" 7 | `; 8 | 9 | exports[`$subslots support Fallback reactive 1`] = ` 10 | "
11 | Fallback is reactive! 12 |
" 13 | `; 14 | 15 | exports[`$subslots support Normal usage 1`] = ` 16 | "
17 |
18 |
19 | Header 20 |
21 |
22 |
23 | 24 | Content 25 | 26 |
27 |
28 |
29 | Footer 30 |
31 |
32 |
" 33 | `; 34 | 35 | exports[`$subslots support Reactive subslots 1`] = ` 36 | "
37 |
38 |
39 | Header is reactive! 40 |
41 |
42 |
43 | 44 | Content is reactive! 45 |
46 |
" 47 | `; 48 | 49 | exports[`$subslots support Should emit "no-match" on no match 1`] = ` 50 | "
51 |
52 | 53 |
54 |
" 55 | `; 56 | 57 | exports[`$subslots support Should support define w/ attributes 1`] = ` 58 | "
59 |
60 |
61 | Header 2 62 |
63 |
64 | Header 3 65 |
66 |
67 |
68 | 69 |
70 |
" 71 | `; 72 | 73 | exports[`$subslots support Should support slice index 1`] = ` 74 | "
75 |
76 |
77 | Header 3 78 |
79 |
80 | Header 4 81 |
82 |
83 | Header 5 84 |
85 |
86 | Header 6 87 |
88 |
89 | Header 7 90 |
91 |
92 | Header 8 93 |
94 |
95 | Header 9 96 |
97 |
98 | Header 10 99 |
100 |
101 |
102 |
103 | Header 1 104 |
105 |
106 | Header 2 107 |
108 |
109 |
" 110 | `; 111 | 112 | exports[`$subslots support Should support slice with limit 1`] = ` 113 | "
114 |
115 |
116 | Header 3 117 |
118 |
119 | Header 4 120 |
121 |
122 |
123 |
124 | Header 1 125 |
126 |
127 | Header 2 128 |
129 |
130 | Header 5 131 |
132 |
133 | Header 6 134 |
135 |
136 | Header 7 137 |
138 |
139 | Header 8 140 |
141 |
142 | Header 9 143 |
144 |
145 | Header 10 146 |
147 |
148 |
" 149 | `; 150 | 151 | exports[`$subslots support Should support slice with no offset 1`] = ` 152 | "
153 |
154 |
155 | Header 1 156 |
157 |
158 | Header 2 159 |
160 |
161 |
162 |
163 | Header 3 164 |
165 |
166 | Header 4 167 |
168 |
169 | Header 5 170 |
171 |
172 | Header 6 173 |
174 |
175 | Header 7 176 |
177 |
178 | Header 8 179 |
180 |
181 | Header 9 182 |
183 |
184 | Header 10 185 |
186 |
187 |
" 188 | `; 189 | 190 | exports[`$subslots support Should use fallback 1`] = ` 191 | "
192 |
193 | Fallback 194 |
195 |
" 196 | `; 197 | -------------------------------------------------------------------------------- /test/filter-attr.test.js: -------------------------------------------------------------------------------- 1 | import {mount} from '@vue/test-utils'; 2 | import Subslot from 'vue-subslot'; 3 | import CardHeader from './fixtures/CardHeader.vue'; 4 | 5 | describe('Subslot', () => { 6 | test('Should support filter attributes', () => { 7 | const Card = { 8 | template: ` 9 |
10 |
11 | 12 |
13 | 14 |
15 | 16 |
17 |
18 | `, 19 | 20 | components: { 21 | Subslot, 22 | CardHeader, 23 | }, 24 | }; 25 | 26 | const wrapper = mount({ 27 | template: ` 28 | 29 | 30 | Header 31 | 32 | 33 | Content 34 | 35 | `, 36 | components: { 37 | Card, 38 | CardHeader, 39 | }, 40 | }); 41 | expect(wrapper.html()).toMatchSnapshot(); 42 | }); 43 | 44 | test('Should support element as a direct reference', () => { 45 | const Card = { 46 | template: ` 47 |
48 |
49 | 50 |
51 | 52 |
53 | 54 |
55 |
56 | `, 57 | 58 | components: { 59 | Subslot, 60 | }, 61 | 62 | data() { 63 | return { 64 | CardHeader, 65 | }; 66 | }, 67 | }; 68 | 69 | const wrapper = mount({ 70 | template: ` 71 | 72 | 73 | Header 74 | 75 | 76 | Content 77 | 78 | `, 79 | components: { 80 | Card, 81 | CardHeader, 82 | }, 83 | }); 84 | expect(wrapper.html()).toMatchSnapshot(); 85 | }); 86 | 87 | test('Should render all', () => { 88 | const Card = { 89 | template: ` 90 |
91 | 92 |
93 | `, 94 | 95 | components: { 96 | Subslot, 97 | }, 98 | 99 | data() { 100 | return { 101 | CardHeader, 102 | }; 103 | }, 104 | }; 105 | 106 | const wrapper = mount({ 107 | template: ` 108 | 109 |
Should render
110 | Should render 111 | 112 |
113 | `, 114 | components: {Card}, 115 | }); 116 | expect(wrapper.html()).toMatchSnapshot(); 117 | }); 118 | 119 | test('Should support tag', () => { 120 | const Card = { 121 | template: ` 122 |
123 | 124 |
125 | `, 126 | 127 | components: { 128 | Subslot, 129 | }, 130 | 131 | data() { 132 | return { 133 | CardHeader, 134 | }; 135 | }, 136 | }; 137 | 138 | const wrapper = mount({ 139 | template: ` 140 | 141 |
Shouldn't render
142 | Shouldn't render 143 | 144 |
145 | `, 146 | components: {Card}, 147 | }); 148 | expect(wrapper.html()).toMatchSnapshot(); 149 | }); 150 | 151 | test('Should support wildcard', () => { 152 | const Card = { 153 | template: ` 154 |
155 | 156 |
157 | `, 158 | 159 | components: { 160 | Subslot, 161 | }, 162 | 163 | data() { 164 | return { 165 | CardHeader, 166 | }; 167 | }, 168 | }; 169 | 170 | const wrapper = mount({ 171 | template: ` 172 | 173 | Shouldn't render 174 | Should render 175 | Shouldn't render 176 | 177 | `, 178 | components: {Card}, 179 | }); 180 | expect(wrapper.html()).toMatchSnapshot(); 181 | }); 182 | 183 | test('Should only be 3 CardHeaders', () => { 184 | const Card = { 185 | template: ` 186 |
187 | 191 |
192 | `, 193 | 194 | components: { 195 | Subslot, 196 | CardHeader, 197 | }, 198 | }; 199 | 200 | const wrapper = mount({ 201 | template: ` 202 | 203 | 207 | Header 208 | 209 | 210 | `, 211 | components: { 212 | Card, 213 | CardHeader, 214 | }, 215 | }); 216 | expect(wrapper.html()).toMatchSnapshot(); 217 | }); 218 | 219 | test('Should enforce offset of 3', () => { 220 | const Card = { 221 | template: ` 222 |
223 | 228 |
229 | `, 230 | 231 | components: { 232 | Subslot, 233 | CardHeader, 234 | }, 235 | }; 236 | 237 | const wrapper = mount({ 238 | template: ` 239 | 240 | 244 | Header {{ i }} 245 | 246 | 247 | `, 248 | components: { 249 | Card, 250 | CardHeader, 251 | }, 252 | }); 253 | expect(wrapper.html()).toMatchSnapshot(); 254 | }); 255 | 256 | test('Should enforce offset and limit without element', () => { 257 | const Card = { 258 | template: ` 259 |
260 | 264 |
265 | `, 266 | 267 | components: { 268 | Subslot, 269 | CardHeader, 270 | }, 271 | }; 272 | 273 | const wrapper = mount({ 274 | template: ` 275 | 276 |
280 | Header {{ i }} 281 |
282 |
283 | `, 284 | components: { 285 | Card, 286 | }, 287 | }); 288 | expect(wrapper.html()).toMatchSnapshot(); 289 | }); 290 | 291 | test('Should support tag', () => { 292 | const Card = { 293 | template: ` 294 |
295 | 296 |
297 | `, 298 | 299 | components: { 300 | Subslot, 301 | }, 302 | }; 303 | 304 | const wrapper = mount({ 305 | template: ` 306 | 307 | Should render 308 |
Shouldn't render
309 | Shouldn't render 310 | 311 |
312 | `, 313 | components: {Card}, 314 | }); 315 | 316 | expect(wrapper.html()).toMatchSnapshot(); 317 | }); 318 | }); 319 | -------------------------------------------------------------------------------- /test/fixtures/CardFooter.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /test/fixtures/CardHeader.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /test/mixin.test.js: -------------------------------------------------------------------------------- 1 | import {mount} from '@vue/test-utils'; 2 | import Vue from 'vue'; 3 | import Subslot from 'vue-subslot'; 4 | import CardHeader from './fixtures/CardHeader.vue'; 5 | import CardFooter from './fixtures/CardFooter.vue'; 6 | 7 | describe('$subslots support', () => { 8 | test('Normal usage', () => { 9 | const Card = { 10 | template: ` 11 |
12 |
16 | 17 |
18 | 19 |
20 | 21 |
22 | 23 | 26 |
27 | `, 28 | 29 | components: { 30 | Subslot, 31 | CardHeader, 32 | }, 33 | 34 | mixins: [ 35 | Subslot.define({ 36 | cardHeader: '@CardHeader:1', 37 | cardFooter: { 38 | element: CardFooter, 39 | limit: 1, 40 | }, 41 | }), 42 | ], 43 | }; 44 | 45 | const wrapper = mount({ 46 | template: ` 47 | 48 | 49 | Header 50 | 51 | 52 | Content 53 | 54 | 55 | Footer 56 | 57 | 58 | `, 59 | components: { 60 | Card, 61 | CardHeader, 62 | CardFooter, 63 | }, 64 | }); 65 | expect(wrapper.html()).toMatchSnapshot(); 66 | }); 67 | 68 | test('Empty slot', () => { 69 | const Card = { 70 | template: ` 71 |
72 |
76 | 77 |
78 |
79 | `, 80 | 81 | components: { 82 | Subslot, 83 | CardHeader, 84 | }, 85 | 86 | mixins: [ 87 | Subslot.define({ 88 | cardHeader: '@CardHeader:1', 89 | }), 90 | ], 91 | }; 92 | 93 | const wrapper = mount({ 94 | template: 'Content', 95 | components: {Card}, 96 | }); 97 | expect(wrapper.html()).toMatchSnapshot(); 98 | }); 99 | 100 | test('Should use fallback', () => { 101 | const Card = { 102 | template: ` 103 |
104 |
105 | 106 | Fallback 107 | 108 |
109 |
110 | `, 111 | 112 | components: { 113 | Subslot, 114 | CardHeader, 115 | }, 116 | 117 | mixins: [ 118 | Subslot.define({ 119 | cardHeader: '@CardHeader:1', 120 | }), 121 | ], 122 | }; 123 | 124 | const wrapper = mount({ 125 | template: 'Content', 126 | components: {Card}, 127 | }); 128 | expect(wrapper.html()).toMatchSnapshot(); 129 | }); 130 | 131 | test('Reactive subslots', async () => { 132 | const Card = { 133 | template: ` 134 |
135 |
136 | 137 |
138 |
139 | 140 |
141 |
142 | `, 143 | 144 | components: { 145 | Subslot, 146 | CardHeader, 147 | }, 148 | 149 | mixins: [ 150 | Subslot.define({ 151 | cardHeader: '@CardHeader:1', 152 | }), 153 | ], 154 | }; 155 | 156 | const wrapper = mount({ 157 | template: ` 158 | 159 | 160 | Header {{ msg }} 161 | 162 | 163 | Content {{ msg }} 164 | 165 | `, 166 | 167 | components: { 168 | Card, 169 | CardHeader, 170 | }, 171 | 172 | data() { 173 | return {msg: 'is not reactive'}; 174 | }, 175 | 176 | methods: { 177 | increment() { 178 | this.msg = 'is reactive!'; 179 | }, 180 | }, 181 | }); 182 | wrapper.vm.increment(); 183 | await Vue.nextTick(); 184 | expect(wrapper.html()).toMatchSnapshot(); 185 | }); 186 | 187 | test('Fallback reactive', async () => { 188 | const Card = { 189 | template: ` 190 |
191 | 192 | Fallback {{ msg }} 193 | 194 |
195 | `, 196 | 197 | components: { 198 | Subslot, 199 | CardHeader, 200 | }, 201 | 202 | mixins: [ 203 | Subslot.define({ 204 | cardHeader: '@CardHeader:1', 205 | }), 206 | ], 207 | 208 | data() { 209 | return {msg: 'is not reactive'}; 210 | }, 211 | 212 | mounted() { 213 | this.msg = 'is reactive!'; 214 | }, 215 | }; 216 | 217 | const wrapper = mount({ 218 | template: ` 219 | 220 | Content 221 | 222 | `, 223 | components: { 224 | Card, 225 | CardHeader, 226 | }, 227 | }); 228 | await Vue.nextTick(); 229 | expect(wrapper.html()).toMatchSnapshot(); 230 | }); 231 | 232 | test('Should support slice index', () => { 233 | const Card = { 234 | template: ` 235 |
236 |
237 | 238 |
239 | 240 |
241 | 242 |
243 |
244 | `, 245 | 246 | components: { 247 | Subslot, 248 | CardHeader, 249 | }, 250 | 251 | mixins: [ 252 | Subslot.define({ 253 | cardHeader: '@CardHeader[2]', 254 | }), 255 | ], 256 | }; 257 | 258 | const wrapper = mount({ 259 | template: ` 260 | 261 | 265 | Header {{ i }} 266 | 267 | 268 | `, 269 | components: { 270 | Card, 271 | CardHeader, 272 | CardFooter, 273 | }, 274 | }); 275 | expect(wrapper.html()).toMatchSnapshot(); 276 | }); 277 | 278 | test('Should support slice with limit', () => { 279 | const Card = { 280 | template: ` 281 |
282 |
283 | 284 |
285 | 286 |
287 | 288 |
289 |
290 | `, 291 | 292 | components: { 293 | Subslot, 294 | CardHeader, 295 | }, 296 | 297 | mixins: [ 298 | Subslot.define({ 299 | cardHeader: '@CardHeader[2:2]', 300 | }), 301 | ], 302 | }; 303 | 304 | const wrapper = mount({ 305 | template: ` 306 | 307 | 311 | Header {{ i }} 312 | 313 | 314 | `, 315 | components: { 316 | Card, 317 | CardHeader, 318 | CardFooter, 319 | }, 320 | }); 321 | expect(wrapper.html()).toMatchSnapshot(); 322 | }); 323 | 324 | test('Should support slice with no offset', () => { 325 | const Card = { 326 | template: ` 327 |
328 |
329 | 330 |
331 | 332 |
333 | 334 |
335 |
336 | `, 337 | 338 | components: { 339 | Subslot, 340 | CardHeader, 341 | }, 342 | 343 | mixins: [ 344 | Subslot.define({ 345 | cardHeader: '@CardHeader[:2]', // Equivalent to [0:2] 346 | }), 347 | ], 348 | }; 349 | 350 | const wrapper = mount({ 351 | template: ` 352 | 353 | 357 | Header {{ i }} 358 | 359 | 360 | `, 361 | components: { 362 | Card, 363 | CardHeader, 364 | CardFooter, 365 | }, 366 | }); 367 | expect(wrapper.html()).toMatchSnapshot(); 368 | }); 369 | 370 | test('Should support define w/ attributes', () => { 371 | const Card = { 372 | template: ` 373 |
374 |
375 | 376 |
377 | 378 |
379 | 380 |
381 |
382 | `, 383 | 384 | components: { 385 | Subslot, 386 | }, 387 | 388 | mixins: [ 389 | Subslot.define({ 390 | cardHeader: { 391 | element: CardHeader, 392 | }, 393 | }), 394 | ], 395 | }; 396 | 397 | const wrapper = mount({ 398 | template: ` 399 | 400 | 404 | Header {{ i }} 405 | 406 | 407 | `, 408 | components: { 409 | Card, 410 | CardHeader, 411 | }, 412 | }); 413 | expect(wrapper.html()).toMatchSnapshot(); 414 | }); 415 | 416 | test('Should emit "no-match" on no match', () => { 417 | const onNoCardHeader = jest.fn(); 418 | 419 | const Card = { 420 | template: ` 421 |
422 |
423 | 427 |
428 |
429 | `, 430 | 431 | components: { 432 | Subslot, 433 | CardHeader, 434 | }, 435 | 436 | mixins: [ 437 | Subslot.define({ 438 | cardHeader: '@CardHeader:1', 439 | }), 440 | ], 441 | 442 | methods: { 443 | onNoCardHeader, 444 | }, 445 | }; 446 | 447 | const wrapper = mount({ 448 | template: 'Content', 449 | components: {Card}, 450 | }); 451 | expect(onNoCardHeader).toBeCalled(); 452 | expect(wrapper.html()).toMatchSnapshot(); 453 | }); 454 | }); 455 | -------------------------------------------------------------------------------- /xo.config.js: -------------------------------------------------------------------------------- 1 | const xoConfig = require('xo/config/plugins'); 2 | 3 | module.exports = { 4 | extensions: ['vue'], 5 | extends: [ 6 | 'plugin:vue/recommended', 7 | ], 8 | rules: { 9 | 'comma-dangle': [ 10 | 'error', 11 | 'always-multiline', 12 | ], 13 | 'guard-for-in': 'off', 14 | 'import/extensions': [ 15 | 'error', 16 | { 17 | ...xoConfig.rules['import/extensions'][1], 18 | vue: 'always', 19 | }, 20 | ], 21 | }, 22 | overrides: [ 23 | { 24 | files: 'test/*', 25 | env: 'jest', 26 | }, 27 | { 28 | files: '**/*.vue', 29 | rules: { 30 | 'import/no-anonymous-default-export': ['error', { 31 | allowObject: true, 32 | }], 33 | 'unicorn/filename-case': ['error', { 34 | cases: { 35 | pascalCase: true, 36 | }, 37 | }], 38 | }, 39 | }, 40 | ], 41 | }; 42 | --------------------------------------------------------------------------------