1 | import {h} from './h'
|
2 | import {patch} from './patch'
|
3 | import {mixin} from './utils/mixin'
|
4 |
|
5 | /**
|
6 | * @description A cross-browser normalization/polyfill for requestAnimationFrame.
|
7 | */
|
8 | const rAF = window && window.requestAnimationFrame
|
9 | || window && window.webkitRequestAnimationFrame
|
10 | || window && window.mozRequestAnimationFrame
|
11 | || window && window.msRequestAnimationFrame
|
12 | || function(cb) { return setTimeout(cb, 16) }
|
13 |
|
14 | /**
|
15 | * @description This is a Time Object used as a key to create a pseudo-private property in the Component class for holding state.
|
16 | * @type {Object} dataStore A Date object to use as pseudo-private key to store the component's state.
|
17 | */
|
18 | const dataStore = new Date().getTime()
|
19 |
|
20 | /**
|
21 | * @description Component can be instantiated with the new keyword, or extended to create a custom version of the class.
|
22 | * @class Class to create a component.
|
23 | * @example New instance of Component class:
|
24 | * const title = new Component({
|
25 | * container: 'header',
|
26 | * state: 'World',
|
27 | * render: message => <h1>Hello, {message}!</h1>
|
28 | * })
|
29 | * @example Extending Component class:
|
30 | * class UserList extends Component {
|
31 | * constructor(props) {
|
32 | * super(props)
|
33 | * this.state = users
|
34 | * this.container = 'section'
|
35 | * }
|
36 | * render(users) {
|
37 | * return (
|
38 | * <ul class='user-list'>
|
39 | * {
|
40 | * users.map(user => <li>{user.name}</li>)
|
41 | * }
|
42 | * </ul>
|
43 | * )
|
44 | * }
|
45 | * }
|
46 | */
|
47 | export class Component {
|
48 | /**
|
49 | * @description Constructor for Component class.
|
50 | *
|
51 | * Possible values to pass to new Component are:
|
52 | * 1. container - element to render component in.
|
53 | * 2. state - data for the component to consume.
|
54 | * 2. render - a function to return markup to create.
|
55 | * 3. componentWillMount - a callback to execute before the component is mount.
|
56 | * 4. componentDidMount - a callback to execute after the component mounts.
|
57 | * 5. componentWillUpdate - a callback to execute before the component updates.
|
58 | * 6. componentDidUpdate - a callback to execute after the component updates.
|
59 | * 7. componentWillUnmount - a callback to execute before the component unmounts.
|
60 | *
|
61 | * @typedef {object} props An object of property/values to configure the class instance.
|
62 | * @property {string|element} props.container The container element in which to render the component.
|
63 | * @property {state} [props.state] The state object of the component. This can be of type boolean, string, number, object or array.
|
64 | * @property {function} props.render A function that returns nodes to render to the DOM.
|
65 | * @constructs Component
|
66 | */
|
67 | constructor(props) {
|
68 | if (!props) props = {}
|
69 | /** @property {string} */
|
70 | this.selector = props.container || 'body'
|
71 |
|
72 | if (props.render) {
|
73 | /** @property {Function} */
|
74 | this.render = props.render
|
75 | }
|
76 |
|
77 | if (props.state) {
|
78 | /** @property {boolean|number|string|object|array} */
|
79 | this.state = props.state
|
80 | }
|
81 |
|
82 | /** @property {null, Object} */
|
83 | this.oldNode = null
|
84 | if (this.selector) {
|
85 | /** @property {HTMLElement} */
|
86 | this.container = document.querySelector(this.selector)
|
87 | }
|
88 |
|
89 | /** @property {boolean} */
|
90 | this.componentShouldUpdate = true
|
91 |
|
92 | /** @property {boolean} */
|
93 | this.mounted = false
|
94 |
|
95 | /**
|
96 | * @property {HTMLElement|undefined}
|
97 | * @default {undefined}
|
98 | */
|
99 | this.element
|
100 |
|
101 | /**
|
102 | * @description Handle lifecycle hooks.
|
103 | */
|
104 | if (props.componentWillMount)
|
105 | this.componentWillMount = props.componentWillMount
|
106 |
|
107 | if (props.componentDidMount)
|
108 | this.componentDidMount = props.componentDidMount
|
109 |
|
110 | if (props.componentWillUpdate)
|
111 | this.componentWillUpdate = props.componentWillUpdate
|
112 |
|
113 | if (props.componentDidUpdate)
|
114 | this.componentDidUpdate = props.componentDidUpdate
|
115 |
|
116 | if (props.componentWillUnmount)
|
117 | this.componentWillUnmount = props.componentWillUnmount
|
118 | }
|
119 |
|
120 | /**
|
121 | * @description This is getter to access the component's state using the pseudo-private key dataStore.
|
122 | * @returns {boolean|number|string|object|any[]} The component's state
|
123 | */
|
124 | get state() {
|
125 | return this[dataStore]
|
126 | }
|
127 |
|
128 | /**
|
129 | * @description This is a setter to define the component's state. It uses the dataStore object as a pseudo-private key. It uses requestAnimationFrame to throttle component updates to avoid layout thrashing.
|
130 | * @param {string|number|boolean|object|array} data Data to set as component state.
|
131 | * @returns {undefined} void
|
132 | */
|
133 | set state(data) {
|
134 | this[dataStore] = data
|
135 | rAF(() => this.update())
|
136 | }
|
137 |
|
138 | /**
|
139 | * @description Method to set a component's state. This accepts simple types or Objects. If updating an array, you can pass in the data and the position (number) in the array to update. Optionally you can pass a callback. This receives the state as its argument. You need to return the state changes in order to update the component's state.
|
140 | * @example
|
141 | * this.setState(true)
|
142 | * this.setState(0)
|
143 | * this.setState({name: 'Joe'})
|
144 | * this.setState([1,2,3])
|
145 | * this.setState(prevState => prevState + 1)
|
146 | * @property Component#setState
|
147 | * @param {string|number|boolean|object|array|Function} data - The data to set.
|
148 | * @param {number} [position] The index of an array whose data you want to set.
|
149 | * @returns {undefined} void
|
150 | */
|
151 | setState(data, position) {
|
152 | if (typeof data === 'function') {
|
153 | const state = data.call(this, this.state)
|
154 | if (typeof state !== 'function' && !!state) this.setState(state)
|
155 | } else if (Array.isArray(this.state)) {
|
156 | const state = this.state
|
157 | if (position || position === 0) {
|
158 | if (typeof state[position] === 'object') {
|
159 | this.state = mixin(state[position], data)
|
160 | } else {
|
161 | state[position] = data
|
162 | this.state = state
|
163 | }
|
164 | } else {
|
165 | this.state = state
|
166 | }
|
167 | } else if (typeof this.state === 'object') {
|
168 | const state = this.state
|
169 | this.state = mixin(state, data)
|
170 | } else {
|
171 | this.state = data
|
172 | }
|
173 | }
|
174 |
|
175 | /**
|
176 | * @description Function to render component after data changes.
|
177 | * If data is passed as argument, it will be used.
|
178 | * Otherwise state will be used.
|
179 | * @property Component#update
|
180 | * @param {boolean|number|string|object|array} [data]
|
181 | * @returns {undefined} void
|
182 | */
|
183 | update(data) {
|
184 | if (!this.render) return
|
185 |
|
186 | // If componentShouldUpdate is set to false, render one time only.
|
187 | // All other updates will be ignored.
|
188 | if (!this.componentShouldUpdate && this.mounted) return
|
189 |
|
190 | // If data is 0 or non-boolean, use,
|
191 | // else use component state.
|
192 | let __data = this.state
|
193 | if (data !== true && data) __data = data
|
194 |
|
195 | if (this.container && typeof this.container === 'string') {
|
196 | this.selector = this.container
|
197 | this.container = document.querySelector(this.container)
|
198 | }
|
199 |
|
200 | // Check if vnode already exists.
|
201 | // Used for deciding whether to fire lifecycle events.
|
202 | const __oldNode = this.oldNode
|
203 | const __render = this.render
|
204 | function testIfVNodesDiffer(oldNode, data) {
|
205 | if (this && JSON.stringify(oldNode) === JSON.stringify(__render(data))) {
|
206 | return false
|
207 | } else {
|
208 | return true
|
209 | }
|
210 | }
|
211 |
|
212 | // Create virtual dom and check if component id
|
213 | // already exists in document.
|
214 | const vdom = this.render(__data)
|
215 | let elem
|
216 | if (
|
217 | vdom
|
218 | && vdom.props
|
219 | && vdom.props.id
|
220 | && this.container
|
221 | ) {
|
222 | elem = this.container && this.container.querySelector(`#${vdom.props.id}`)
|
223 | }
|
224 |
|
225 | // If component element id already exists in DOM,
|
226 | // remove it before rendering the component.
|
227 | if (elem && !this.mounted) {
|
228 | elem.parentNode.removeChild(elem)
|
229 | }
|
230 |
|
231 | // Patch DOM with component update.
|
232 | this.oldNode = this.render(__data)
|
233 | this.element = patch(
|
234 | this.oldNode,
|
235 | this.element
|
236 | )
|
237 | if (!this.mounted) {
|
238 | this.componentWillMount && this.componentWillMount(this)
|
239 | this.container.appendChild(this.element)
|
240 | this.mounted = true
|
241 | this.componentDidMount && this.componentDidMount(this)
|
242 | return
|
243 | }
|
244 |
|
245 | if (this.mounted && this.oldNode && testIfVNodesDiffer(__oldNode, __data)) {
|
246 | this.componentWillUpdate && this.componentWillUpdate(this)
|
247 | }
|
248 | this.componentDidUpdate && testIfVNodesDiffer(__oldNode, __data) && this.componentDidUpdate(this)
|
249 | }
|
250 |
|
251 | /**
|
252 | * @description Method to destroy component.
|
253 | * First unbind events.
|
254 | * Then remove component element from DOM.
|
255 | * Also null out component properties.
|
256 | * @property Component#unmount
|
257 | * @returns {undefined} void
|
258 | */
|
259 | unmount() {
|
260 | const self = this
|
261 | const eventWhitelist = [
|
262 | 'change',
|
263 | 'click',
|
264 | 'dblclick',
|
265 | 'input',
|
266 | 'keydown',
|
267 | 'keypress',
|
268 | 'keyup',
|
269 | 'mousedown',
|
270 | 'mouseleave',
|
271 | 'mouseout',
|
272 | 'mouseover',
|
273 | 'mouseup',
|
274 | 'pointercancel',
|
275 | 'pointerdown',
|
276 | 'pointermove',
|
277 | 'pointerup',
|
278 | 'select',
|
279 | 'submit',
|
280 | 'touchcancel',
|
281 | 'touchend',
|
282 | 'touchmove',
|
283 | 'touchstart'
|
284 | ]
|
285 | if (!this.element) return
|
286 | this.componentWillUnmount && this.componentWillUnmount(this)
|
287 | eventWhitelist.map(event => {
|
288 | this.element.removeEventListener(event, this)
|
289 | })
|
290 | this.container.removeChild(this.element)
|
291 | this.container = undefined
|
292 | for (let key in this) {
|
293 | delete this[key]
|
294 | }
|
295 | delete this.state
|
296 | this.update = undefined
|
297 | this.unmount = undefined
|
298 | }
|
299 | }
|