@lightningjs/blits 1.13.1 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.14.0
4
+
5
+ _6 jan 2025_
6
+
7
+ - Added ability to deregister listeners (`this.$unlisten()`)
8
+ - Added autosize to images without `w` and `h` attributes
9
+ - Fixed cleanup of transitions and end-callbacks when Elements are destroyed
10
+ - Added support for reactively updating an entire object (instead of having to update each object key individually)
11
+ - Added `this.$size()` method to set the dimensions of a Component
12
+ - Added `intercept` input method to handle key presses before they reach the currently focused Component
13
+ - Added support for query-parameters in routes (in addition to regular query params)
14
+
15
+
3
16
  ## v1.13.1
4
17
 
5
18
  _9 dec 2024_
@@ -56,7 +69,7 @@ _8 nov 2024_
56
69
  - Fixed issue with watching nested state variables and global state
57
70
  - Upgraded to renderer 2.6.2
58
71
  - Fixed issue with white background for Elements with falsy src attribute
59
- - Fixed issue with calling focus on component that already is focussed
72
+ - Fixed issue with calling focus on component that already is focused
60
73
 
61
74
 
62
75
  ## v1.9.2
@@ -55,6 +55,38 @@ The Layout component also uses this event, to execute its calculation and positi
55
55
 
56
56
  When the children of the Layout-component have _reactive_ dimensions (i.e. `<Element :w="$mywidth" :h="$myheight" />), the Layout component ensures that all child elements are properly re-positioned whenever a dimension changes.
57
57
 
58
+ ### Components inside a Layout
59
+
60
+ It is also possible to place Components inside of a Layout, but there is a small _gotcha_ there. By default a Component does not have any dimensions - it has a width and height of `0`, regardless of the contents of the Component. Normally, when using absolute positioning, this isn't a problem. But in the context of a Layout, each child needs to have dimensions.
61
+
62
+ If the Component has fixed dimensions, you can just add a `w` and a `h` attribute to the Component tag (i.e. `<MyButton w="100" h="40" />`). This is the most performant way to supply dimensions to a Component and should be used whenever possible.
63
+
64
+ If the Component has dynamic dimensions that are not known upfront, you can dynamically update the dimensions from inside the Component by calling the `this.$size()`-method. This method accepts an object as its argument with a `w` property for setting the _width_ and a `h` property for setting the _height_.
65
+
66
+ ```js
67
+ export default Blits.Component('MyButton', {
68
+ template: ``,
69
+ props: ['type'],
70
+ hooks: {
71
+ ready() {
72
+ if(this.type === 'large') {
73
+ this.$size({
74
+ w: 200,
75
+ h: 80
76
+ })
77
+ } else {
78
+ this.$size({
79
+ w: 100,
80
+ h: 40
81
+ })
82
+ }
83
+
84
+ }
85
+ }
86
+ })
87
+ ```
88
+ At this moment Blits does not have support for automatically growing the dimensions of a Component based on it's contents, because of the performance impact that this functionality has.
89
+
58
90
  ### Nesting Layouts
59
91
 
60
92
  It's also possible to nest layouts. Simply place a new `<Layout>`-tag, with it's own children in between the children of another Layout-component. The Layout-component itself will grow automatically with the dimensions of it's children. In other words, it's not required to specify a width (`w`) or height (`h`) on the `<Layout>`-tag itself.
@@ -72,6 +72,16 @@ When a component handles a key press by having a corresponding function specifie
72
72
  }
73
73
  ```
74
74
 
75
+ ## Intercepting key input
76
+
77
+ In addition to the Event handling chain explained above. Blits offers the option to _intercept_ key presses at the root level of the Application, before they reach the currently focused Component. This can be useful in certain situation where you want to globally disable all key presses, or when implementing an override key press handler.
78
+
79
+ The `intercept()` input-method can only be implemented in the `Blits.Application`-component. When present, the method acts as a _catch-all_ method, and will be executed for _all_ key presses. It receives the `KeyboardEvent` as its argument, allowing you to execute logic based on the key being pressed.
80
+
81
+ Only when the `intercept()` input-method returns the `KeyboardEvent` (possibly modified), the keypress will continue to be handled (by the currently focused Component).
82
+
83
+ The `intercept`-method can also be an asynchronous method.
84
+
75
85
  ## Key-up handling
76
86
 
77
87
  The functions specified in the `input` configuration are invoked when a key is _pressed down_ (i.e. the `keydown` event listener). But sometimes you may also want to execute some logic when a key is _released_ (i.e. the `keyup` event listener).
@@ -177,6 +177,42 @@ export default Blits.Component('MyComponent', {
177
177
  })
178
178
  ```
179
179
 
180
+ ### $unlisten
181
+
182
+ The `$unlisten` utility method (which is available on each Blits Component) is designed to remove event listeners that were previously registered using `$listen()`. This method helps in managing event listeners manually when needed.
183
+
184
+ The first argument of the `$unlisten()` method is the event `name` to stop listening for. This ensures that all listeners for the specified event are removed from the component.
185
+
186
+ Generally, you might not need to call `$unlisten()` manually, as Blits automatically deregisters listeners when a Component is destroyed. However, it can be useful in scenarios where you need to stop listening for events before the Component is destroyed.
187
+
188
+ ```js
189
+ export default Blits.Component('MyComponent', {
190
+ hooks: {
191
+ init() {
192
+ this.activateListener = () => {
193
+ console.log('activated')
194
+ this.text = 'We are active!'
195
+ }
196
+
197
+ this.changeBackgroundListener = (data) => {
198
+ setTimeout(() => {
199
+ this.backgroundImage = data.img
200
+ }, data.delay)
201
+ }
202
+
203
+ // Register listeners
204
+ this.$listen('activate', this.activateListener)
205
+ this.$listen('changeBackground', this.changeBackgroundListener)
206
+ },
207
+ unfocus() {
208
+ // Remove listeners when Component is unfocused
209
+ this.$unlisten('activate')
210
+ this.$unlisten('changeBackground')
211
+ }
212
+ }
213
+ })
214
+ ```
215
+
180
216
  ## Timeouts
181
217
 
182
218
  Setting a timeout is a typical way in JS to execute functionality with a delay. But timeouts can also be a common cause of
@@ -30,10 +30,19 @@ Each route in this array, is an Object literal that includes the following key p
30
30
 
31
31
  Besides static routes such as `/account` and `/settings/wifi/advanced`, the Blits router also supports dynamic routes where URI parts can contain params.
32
32
 
33
- To define a param in a route path prefix a string with a colon (`:`), followed by the name of the param, e.g. `/movies/:genre/:id`. This route path will match a URI hash such as `#/movies/sci-fi/65281918`. The router will take the params and inject them as a `prop` into the Page that's being navigated to.
33
+ To define a param in a route path, prefix a string with a colon (`:`), followed by the name of the param, e.g. `/movies/:genre/:id`. This route path will match a URI hash such as `#/movies/sci-fi/65281918`. The router will take the params and inject them as a `prop` into the Page that's being navigated to.
34
34
 
35
- To access these props in the Page component, they must first be defined in the Component first (i.e. `props: ['genre', 'id']`). Once defined, the values (`sci-fi` and `65281918`) in this case, will be available on the `this` scope, as with any regular Component.
35
+ To access these props in the Page component, they must first be defined in the Page component as _props_ first (i.e. `props: ['genre', 'id']`). Once defined, the values (`sci-fi` and `65281918`) in this case, will be available on the `this` scope as `this.genre` and `this.id` (or as `$genre` and `$id` when used in the template), as with any regular Component.
36
36
 
37
+ #### Using query parameters in routes
38
+
39
+ According to the browser specifications, query parameters are not part of a URL hash. This means that query parameters should be placed _before_ the URL hash (i.e. `http://localhost:5173?id=100&name=john#/my/page/hash`) and implies that "real" query parameters are _not_ part of the route paths.
40
+
41
+ In this case `id` and `name` won't automatically be made available as props inside a Blits component. They can however be retrieved using `document.location.search` in combination with `new URLSearchParams`, and can be used in an App's `index.js` to set dynamic launch settings for example.
42
+
43
+ Sometimes you may actually want pass custom data into a Blits component, without it being part of the dynamic route path or `data`-object during navigation. For these cases Blits allows you to use query parameters as part of a route (i.e. `#/series/simpsons/5/10?id=100&name=john`).
44
+
45
+ This URL hash will match the route `/series/:show/:season/:episode` and it will pass `id` and `name` as additional props into the Page component. Note: similar to dynamic path params, route query params should also be be defined in the Page component as _props_ first (i.e. `props: ['id', 'name']`) in order to be accessed on the `this`-scope.
37
46
 
38
47
  ## Router view
39
48
 
package/index.d.ts CHANGED
@@ -81,14 +81,26 @@ declare module '@lightningjs/blits' {
81
81
  }
82
82
 
83
83
  export interface Input {
84
- [key: string]: (event: KeyboardEvent) => void | undefined,
84
+ [key: string]: (event: KeyboardEvent) => void | undefined | unknown,
85
85
  /**
86
86
  * Catch all input function
87
87
  *
88
88
  * Will be invoked when there is no dedicated function for a certain key
89
89
  */
90
90
  // @ts-ignore
91
- any?: (event: KeyboardEvent) => void
91
+ any?: (event: KeyboardEvent) => void,
92
+ /**
93
+ * Intercept key presses on the root Application component before being handled
94
+ * by the currently focused component.
95
+ *
96
+ * Only when a KeyboardEvent (the original one, or a modified one) is returned from the
97
+ * intercept function, the Input event is passed on to the Component with focus.
98
+ *
99
+ * The intercept function can be asynchronous.
100
+ *
101
+ * Note: the intercept input handler is only available on the Root App component (i.e. Blits.Application)
102
+ */
103
+ intercept?: (event: KeyboardEvent) => KeyboardEvent | Promise<KeyboardEvent | any> | any
92
104
  }
93
105
 
94
106
  export interface Log {
@@ -153,7 +165,9 @@ declare module '@lightningjs/blits' {
153
165
 
154
166
  export type ComponentBase = {
155
167
  /**
156
- * Check if a component has focus
168
+ * Indicates whether the component currently has focus
169
+ *
170
+ * @returns Boolean
157
171
  */
158
172
  hasFocus: boolean,
159
173
 
@@ -244,11 +258,23 @@ declare module '@lightningjs/blits' {
244
258
  * Deprecated: use `this.$trigger()` instead
245
259
  */
246
260
  trigger: (key: string) => void
247
-
248
261
  /**
249
262
  * Router instance
250
263
  */
251
264
  $router: Router
265
+ /**
266
+ * Dynamically set the size of a component holder node
267
+ */
268
+ $size: (dimensions: {
269
+ /**
270
+ * Component width
271
+ */
272
+ w: number,
273
+ /**
274
+ * Component height
275
+ */
276
+ h: number
277
+ }) => void
252
278
  }
253
279
 
254
280
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightningjs/blits",
3
- "version": "1.13.1",
3
+ "version": "1.14.0",
4
4
  "description": "Blits: The Lightning 3 App Development Framework",
5
5
  "bin": "bin/index.js",
6
6
  "exports": {
@@ -56,8 +56,15 @@ const Application = (config) => {
56
56
  config.hooks[symbols.init] = function () {
57
57
  const keyMap = { ...defaultKeyMap, ...Settings.get('keymap', {}) }
58
58
 
59
- keyDownHandler = (e) => {
59
+ keyDownHandler = async (e) => {
60
60
  const key = keyMap[e.key] || keyMap[e.keyCode] || e.key || e.keyCode
61
+ // intercept key press if specified in main Application component
62
+ if (this[symbols.inputEvents].intercept !== undefined) {
63
+ e = await this[symbols.inputEvents].intercept.call(this, e)
64
+ // only pass on the key press to focused component when keyboard event is returned
65
+ if (e instanceof KeyboardEvent === false) return
66
+ }
67
+
61
68
  Focus.input(key, e)
62
69
  clearTimeout(holdTimeout)
63
70
  holdTimeout = setTimeout(() => {
@@ -35,4 +35,12 @@ export default {
35
35
  enumerable: true,
36
36
  configurable: false,
37
37
  },
38
+ $unlisten: {
39
+ value: function (event) {
40
+ eventListeners.deregisterListener(this, event)
41
+ },
42
+ writable: false,
43
+ enumerable: true,
44
+ configurable: false,
45
+ },
38
46
  }
@@ -20,6 +20,15 @@ import symbols from '../../lib/symbols.js'
20
20
  import { renderer } from '../../launch.js'
21
21
 
22
22
  export default {
23
+ $size: {
24
+ value: function (dimensions = { w: 0, h: 0 }) {
25
+ this[symbols.holder].set('w', dimensions.w || 0)
26
+ this[symbols.holder].set('h', dimensions.h || 0)
27
+ },
28
+ writable: false,
29
+ enumerable: true,
30
+ configurable: false,
31
+ },
23
32
  [symbols.renderer]: {
24
33
  value: () => renderer,
25
34
  writable: false,
@@ -217,6 +217,10 @@ const propsTransformer = {
217
217
  if (this.raw['color'] === undefined) {
218
218
  this.props['color'] = this.props['src'] ? 0xffffffff : 0x00000000
219
219
  }
220
+ // apply auto sizing when no width or height specified
221
+ if (!('w' in this.raw) && !('w' in this.raw) && !('h' in this.raw) && !('height' in this.raw)) {
222
+ this.props['autosize'] = true
223
+ }
220
224
  },
221
225
  set texture(v) {
222
226
  this.props['texture'] = v
@@ -536,12 +540,18 @@ const Element = {
536
540
  }
537
541
 
538
542
  f.once('stopped', () => {
539
- // remove the prop from scheduled transitions
540
- this.scheduledTransitions[prop] = undefined
543
+ if (
544
+ this.scheduledTransitions[prop] !== undefined &&
545
+ this.scheduledTransitions[prop].canceled === true
546
+ ) {
547
+ return
548
+ }
541
549
  // fire transition end callback when animation ends (if specified)
542
550
  if (transition.end && typeof transition.end === 'function') {
543
551
  transition.end.call(this.component, this, prop, this.node[prop])
544
552
  }
553
+ // remove the prop from scheduled transitions
554
+ delete this.scheduledTransitions[prop]
545
555
  })
546
556
 
547
557
  // start animation
@@ -550,6 +560,16 @@ const Element = {
550
560
  destroy() {
551
561
  Log.debug('Deleting Node', this.nodeId)
552
562
  this.node.destroy()
563
+
564
+ // Clearing transition end callback functions
565
+ const transitionProps = Object.keys(this.scheduledTransitions)
566
+ for (let i = 0; i < transitionProps.length; i++) {
567
+ const transition = this.scheduledTransitions[transitionProps[i]]
568
+ if (transition !== undefined) {
569
+ transition.canceled = true
570
+ if (transition.f !== undefined) transition.f.stop()
571
+ }
572
+ }
553
573
  },
554
574
  get nodeId() {
555
575
  return this.node && this.node.id
@@ -36,6 +36,19 @@ export default {
36
36
  callbackCache.delete(event) // Invalidate the callbackCache when a new callback is added
37
37
  },
38
38
 
39
+ deregisterListener(component, event) {
40
+ let componentsMap = eventsMap.get(event)
41
+ if (componentsMap === undefined) {
42
+ return;
43
+ }
44
+
45
+ if (componentsMap.contains(component)) {
46
+ componentsMap.delete(component);
47
+ eventsMap.set(event, componentsMap)
48
+ callbackCache.delete(event)
49
+ }
50
+ },
51
+
39
52
  executeListeners(event, params) {
40
53
  const componentsMap = eventsMap.get(event)
41
54
  if (componentsMap === undefined || componentsMap.size === 0) {
@@ -130,6 +130,53 @@ test('executeListeners - stop propagation', (t) => {
130
130
  t.end()
131
131
  })
132
132
 
133
+ test('deregisterListener', (t) => {
134
+ resetModule()
135
+
136
+ const component = 'testComponent'
137
+ const event = 'testEvent'
138
+ let callCount = 0
139
+ const cb = () => {
140
+ callCount++
141
+ }
142
+
143
+ eventListener.registerListener(component, event, cb)
144
+ eventListener.deregisterListener(component, event)
145
+
146
+ const result = eventListener.executeListeners(event)
147
+ t.equal(result, true, 'Should return true when no listeners are registered after deregistration')
148
+ t.equal(callCount, 0, 'Should not call deregistered callback')
149
+
150
+ t.end()
151
+ })
152
+
153
+ test('deregisterListener - only deregisters specified component listener', (t) => {
154
+ resetModule()
155
+
156
+ const componentA = 'testComponent'
157
+ const componentB = 'testComponentB'
158
+ const event = 'testEvent'
159
+ let callCount1 = 0
160
+ let callCount2 = 0
161
+
162
+ eventListener.registerListener(componentA, event, () => {
163
+ callCount1++
164
+ })
165
+
166
+ eventListener.registerListener(componentB, event, () => {
167
+ callCount2++
168
+ })
169
+
170
+ eventListener.deregisterListener(componentA, event)
171
+
172
+ const result = eventListener.executeListeners(event)
173
+ t.equal(result, true, 'Should return true when listeners are still registered after deregistering one')
174
+ t.equal(callCount1, 0, 'Should not call deregistered callback')
175
+ t.equal(callCount2, 1, 'Should call remaining registered callback')
176
+
177
+ t.end()
178
+ })
179
+
133
180
  test('removeListeners', (t) => {
134
181
  resetModule()
135
182
 
@@ -107,7 +107,15 @@ const reactiveProxy = (original, _parent = null, _key, global) => {
107
107
  : oldRawValue === rawValue
108
108
 
109
109
  if (isEqual === false) {
110
- if (Array.isArray(value) === true) value = getRaw(value).slice(0)
110
+ if (Array.isArray(value) === true) {
111
+ value = getRaw(value).slice(0)
112
+ } else if (
113
+ typeof value === 'object' &&
114
+ target[key] !== null &&
115
+ Array.isArray(target) === false
116
+ ) {
117
+ value = Object.assign(receiver[key], value)
118
+ }
111
119
  result = Reflect.set(target, key, value, receiver)
112
120
  }
113
121
 
@@ -35,7 +35,11 @@ let navigatingBack = false
35
35
  let previousFocus
36
36
 
37
37
  export const getHash = () => {
38
- return (document.location.hash || '/').replace(/^#/, '')
38
+ const hashParts = (document.location.hash || '/').replace(/^#/, '').split('?')
39
+ return {
40
+ hash: hashParts[0],
41
+ queryParams: new URLSearchParams(hashParts[1]),
42
+ }
39
43
  }
40
44
 
41
45
  const normalizePath = (path) => {
@@ -113,7 +117,7 @@ export const navigate = async function () {
113
117
  navigating = true
114
118
  if (this.parent[symbols.routes]) {
115
119
  const previousRoute = currentRoute
116
- const hash = getHash()
120
+ const { hash, queryParams } = getHash()
117
121
  let route = matchHash(hash, this.parent[symbols.routes])
118
122
  let beforeHookOutput
119
123
  if (route) {
@@ -153,7 +157,16 @@ export const navigate = async function () {
153
157
  holder.set('w', '100%')
154
158
  holder.set('h', '100%')
155
159
  // merge props with potential route params, navigation data and route data to be injected into the component instance
156
- const props = { ...this[symbols.props], ...route.params, ...navigationData, ...route.data }
160
+ const props = {
161
+ ...this[symbols.props],
162
+ ...route.params,
163
+ ...navigationData,
164
+ ...route.data,
165
+ ...queryParams.entries().reduce((obj, item) => {
166
+ obj[item[0]] = item[1]
167
+ return obj
168
+ }, {}),
169
+ }
157
170
 
158
171
  view = await route.component({ props }, holder, this)
159
172
  if (view[Symbol.toStringTag] === 'Module') {