├── .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 |
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);
--------------------------------------------------------------------------------