├── .gitignore ├── assets └── star_icon.png ├── package.json ├── webpack.config.js ├── README.md └── src ├── index.html └── rating.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | -------------------------------------------------------------------------------- /assets/star_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thinktecture/native-web-components/HEAD/assets/star_icon.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-component-demo", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "start": "webpack-dev-server src/index.html", 6 | "build-wc": "npm run build-wc:clean && npm run build-wc:webpack", 7 | "build-wc:clean": "rm -rf dist && mkdir dist", 8 | "build-wc:webpack": "webpack" 9 | }, 10 | "dependencies": {}, 11 | "devDependencies": { 12 | "file-loader": "^4.2.0", 13 | "html-loader": "^0.5.5", 14 | "webpack": "^4.39.3", 15 | "webpack-cli": "^3.3.8", 16 | "webpack-dev-server": "^3.8.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mode: 'production', 3 | entry: { 4 | 'rating': './src/rating.js', 5 | }, 6 | output: { 7 | filename: 'my-rating.js', 8 | }, 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.(html)$/, 13 | use: { 14 | loader: 'html-loader', 15 | }, 16 | }, 17 | { 18 | test: /\.(png|jpe?g|gif)$/i, 19 | use: [ 20 | { 21 | loader: 'file-loader', 22 | }, 23 | ], 24 | }, 25 | ], 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Build native Web Components without using a framework 2 | 3 | Everyone knows it: Encapsulating and reusing UI components on the web is challenging. It is usually a copy & paste of HTML, CSS, and JavaScript, often spread over one or more files. If you forget a part, it does not look as desired, or the interaction does not work. Enough of that! 4 | Web Components open up new ways on the web to implement and (re-)use UI components in a standardized manner and without any framework. In this sample, i want to show the essential points to create a completely native Web Component with standards HTML Templates, Custom Elements, and Shadow DOM. 5 | 6 | To run the sample, following the next steps. 7 | 8 | ## Install dependencies 9 | Run `npm i` to install all dependencies. 10 | 11 | ## Development server 12 | Run `npm start` for a dev server. Navigate to http://localhost:8080/. The app will not automatically reload if you change any of the source files. 13 | 14 | ## Build 15 | Run `npm run build-wc:webpack` to build the web component. The build artifacts will be stored in the dist/ directory. -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Native Rating WebComponent 6 | 7 | 32 | 33 | 34 | 35 | 36 |

Styled Rating Web Component

37 |
38 | 39 | 40 | -------------------------------------------------------------------------------- /src/rating.js: -------------------------------------------------------------------------------- 1 | const template = document.createElement('template'); 2 | template.innerHTML = ` 3 | 63 | 64 |

Rating Web Component

65 |
66 |
67 | 68 |
69 |
70 |
71 | `; 72 | 73 | export class Rating extends HTMLElement { 74 | 75 | static get observedAttributes() { 76 | return ['rating', 'max-rating']; 77 | } 78 | 79 | constructor() { 80 | super(); 81 | // attach to the Shadow DOM 82 | const root = this.attachShadow({mode: 'closed'}); 83 | root.appendChild(template.content.cloneNode(true)); 84 | this.element = root.querySelector('div'); 85 | const slot = this.element.querySelector('slot'); 86 | this.slotNode = slot.querySelector('div'); 87 | slot.addEventListener('slotchange', event => { 88 | // Take first element of the slot and assign it as new rating star template 89 | const node = slot.assignedNodes()[0]; 90 | if (node) { 91 | this.slotNode = node; 92 | this.render(); 93 | } 94 | }); 95 | } 96 | 97 | get ratingName() { 98 | return this.getAttribute('rating-name'); 99 | } 100 | 101 | set ratingName(value) { 102 | this.setAttribute('rating-name', value); 103 | } 104 | 105 | get maxRating() { 106 | return +this.getAttribute('max-rating'); 107 | } 108 | 109 | set maxRating(value) { 110 | this.setAttribute('max-rating', value); 111 | } 112 | 113 | get rating() { 114 | return +this.getAttribute('rating'); 115 | } 116 | 117 | set rating(value) { 118 | if (value < 0) { 119 | throw new Error('The rating must be higher than zero.'); 120 | } 121 | const currentRating = +value; 122 | if (currentRating > this.maxRating) { 123 | throw new Error('The rating must be lower than the maximum.'); 124 | } 125 | this.setAttribute('rating', value); 126 | } 127 | 128 | 129 | connectedCallback () { 130 | // set default value for maximal rating value 131 | if (!this.maxRating) { 132 | this.maxRating = 5; 133 | } else if(this.maxRating < 0) { 134 | throw new Error('The rating must be higher than zero.'); 135 | } 136 | // set default value for rating 137 | if (!this.rating) { 138 | this.rating = 0; 139 | } else if (this.rating < 0 || this.rating > this.maxRating) { 140 | throw new Error('The rating must be higher than zero and lower than the maximum.'); 141 | } 142 | this.dispatchEvent(new CustomEvent('ratingChanged', { detail: this.rating })); 143 | this.render(); 144 | } 145 | 146 | attributeChangedCallback(name, oldVal, newVal) { 147 | if (oldVal === newVal) { 148 | return; 149 | } 150 | 151 | switch (name) { 152 | case 'rating': 153 | this.rating = newVal; 154 | this.updateRating(); 155 | break; 156 | case 'max-rating': 157 | this.maxRating = newVal; 158 | this.render(); 159 | break; 160 | } 161 | } 162 | 163 | render() { 164 | this.clearRatingElements(); 165 | for (let i = this.maxRating; i > 0; i--) { 166 | i = parseInt(i); 167 | const selected = this.rating ? this.rating >= i : false; 168 | this.createRatingStar(selected, i); 169 | } 170 | } 171 | 172 | clearRatingElements() { 173 | const nodes = this.element.getElementsByClassName('rating-item'); 174 | if (nodes) { 175 | while (nodes.length > 0) { 176 | nodes[0].parentNode.removeChild(nodes[0]); 177 | } 178 | } 179 | } 180 | 181 | createRatingStar(selected, itemId) { 182 | const ratingTemplate = document.createElement('div'); 183 | ratingTemplate.setAttribute('class', selected ? `rating-item item-${itemId} selected` : `rating-item item-${itemId}`); 184 | ratingTemplate.appendChild(this.slotNode.cloneNode(true)); 185 | ratingTemplate.addEventListener('click', value => { 186 | this.changeRating(itemId); 187 | }); 188 | this.element.appendChild(ratingTemplate); 189 | } 190 | 191 | changeRating(event) { 192 | this.rating = event; 193 | this.updateRating(); 194 | this.dispatchEvent(new CustomEvent('ratingChanged', { detail: this.rating })); 195 | } 196 | 197 | updateRating() { 198 | for (let currentRating = 1; currentRating <= this.maxRating; currentRating++) { 199 | let ratingItem = this.element.getElementsByClassName(`item-${currentRating}`)[0]; 200 | if (ratingItem) { 201 | if (currentRating <= this.rating) { 202 | if (ratingItem.className.indexOf('selected') < 0) { 203 | ratingItem.className = ratingItem.className + ' selected'; 204 | } 205 | } else { 206 | ratingItem.className = ratingItem.className.replace('selected', ''); 207 | } 208 | } 209 | } 210 | } 211 | } 212 | 213 | window.customElements.define('my-rating', Rating); --------------------------------------------------------------------------------