@lightningtv/solid 2.9.9 → 2.10.0-1

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.
@@ -13,7 +13,6 @@ import { ElementNode } from '@lightningtv/solid';
13
13
  export function Visible<T>(props: {
14
14
  when: T | undefined | null | false;
15
15
  keyed?: boolean;
16
- fallback?: JSX.Element;
17
16
  children: JSX.Element;
18
17
  }): JSX.Element {
19
18
  let child: ChildrenReturn | undefined;
@@ -55,6 +54,6 @@ export function Visible<T>(props: {
55
54
  }
56
55
  });
57
56
 
58
- return c ? child : props.fallback;
57
+ return c || child ? child : null;
59
58
  }) as unknown as JSX.Element;
60
59
  };
@@ -24,7 +24,7 @@ export {
24
24
  chainFunctions,
25
25
  chainRefs,
26
26
  } from './utils/chainFunctions.js';
27
- export { handleNavigation, onGridFocus } from './utils/handleNavigation.js';
27
+ export * from './utils/handleNavigation.js';
28
28
  export { createSpriteMap, type SpriteDef } from './utils/createSpriteMap.js';
29
29
 
30
30
  export type * from './types.js';
@@ -1,110 +1,328 @@
1
- import { ElementNode, assertTruthy, Config } from '@lightningtv/core';
2
- import { type KeyHandler } from '@lightningtv/core/focusManager';
3
- import type { NavigableElement, OnSelectedChanged } from '../types.js';
4
-
5
- export function onGridFocus(onSelectedChanged: OnSelectedChanged | undefined) {
6
- return function (this: ElementNode) {
7
- if (!this || this.children.length === 0) return false;
8
-
9
- // if a child already has focus, assume that should be selected
10
- this.children.find((child, index) => {
11
- if (child.states.has(Config.focusStateKey)) {
12
- this.selected = index;
13
- return true;
14
- }
15
- return false;
16
- });
1
+ import * as s from 'solid-js';
2
+ import * as lng from '@lightningtv/solid';
3
+ import * as lngp from '@lightningtv/solid/primitives';
17
4
 
18
- this.selected = this.selected || 0;
19
- let child = this.selected
20
- ? this.children[this.selected]
21
- : this.selectedNode;
5
+ declare module '@lightningtv/core' {
6
+ interface ElementNode {
7
+ /** For children of {@link lngp.NavigableElement}, set to `true` to prevent being selected */
8
+ skipFocus?: boolean;
9
+ }
10
+ }
22
11
 
23
- while (child?.skipFocus) {
24
- this.selected++;
25
- child = this.children[this.selected];
12
+ function idxInArray(idx: number, arr: readonly any[]): boolean {
13
+ return idx >= 0 && idx < arr.length;
14
+ }
15
+
16
+ function findFirstFocusableChildIdx(
17
+ el: lngp.NavigableElement,
18
+ from = 0,
19
+ delta = 1,
20
+ ): number {
21
+ for (let i = from; ; i += delta) {
22
+ if (!idxInArray(i, el.children)) {
23
+ if (el.wrap) {
24
+ i = (i + el.children.length) % el.children.length;
25
+ } else break;
26
+ }
27
+ if (!el.children[i]!.skipFocus) {
28
+ return i;
26
29
  }
27
- if (!(child instanceof ElementNode)) return false;
30
+ }
31
+ return -1;
32
+ }
33
+
34
+ function selectChild(el: lngp.NavigableElement, index: number): boolean {
35
+ const child = el.children[index];
36
+
37
+ if (child == null || child.skipFocus) {
38
+ el.selected = -1;
39
+ return false;
40
+ }
41
+
42
+ const lastSelected = el.selected;
43
+ el.selected = index;
44
+
45
+ if (!lng.isFocused(child)) {
28
46
  child.setFocus();
47
+ }
29
48
 
30
- if (onSelectedChanged) {
31
- const grid = this as NavigableElement;
32
- onSelectedChanged.call(grid, grid.selected, grid, child);
33
- }
34
- return true;
49
+ // Always call onSelectedChanged on first focus for clients
50
+ el.onSelectedChanged?.(index, el, child as lng.ElementNode, lastSelected);
51
+
52
+ return true;
53
+ }
54
+
55
+ /** @deprecated Use {@link navigableForwardFocus} instead */
56
+ export function onGridFocus(
57
+ _?: lngp.OnSelectedChanged,
58
+ ): lng.ForwardFocusHandler {
59
+ return function () {
60
+ return navigableForwardFocus.call(this, this);
35
61
  };
36
62
  }
37
63
 
64
+ /**
65
+ * Forwards focus to the first focusable child of a {@link lngp.NavigableElement} and
66
+ * selects it.
67
+ *
68
+ * @example
69
+ * ```tsx
70
+ * <view
71
+ * selected={0}
72
+ * forwardFocus={navigableForwardFocus}
73
+ * onSelectedChanged={(idx, el, child, lastIdx) => {...}}
74
+ * >
75
+ * ```
76
+ */
77
+ export const navigableForwardFocus: lng.ForwardFocusHandler = function () {
78
+ const navigable = this as lngp.NavigableElement;
79
+
80
+ // Undo for now - We should only do this when setFocus is called rather than on forwardFocus
81
+ // needs some more research
82
+ // if (!lng.isFocused(this)) {
83
+ // // if a child already has focus, assume that should be selected
84
+ // for (let [i, child] of this.children.entries()) {
85
+ // if (lng.isFocused(child)) {
86
+ // this.selected = i;
87
+ // break;
88
+ // }
89
+ // }
90
+ // }
91
+
92
+ let selected = navigable.selected;
93
+ selected = idxInArray(selected, this.children) ? selected : 0;
94
+ selected = findFirstFocusableChildIdx(navigable, selected);
95
+ return selectChild(navigable, selected);
96
+ };
97
+
98
+ /** @deprecated Use {@link navigableHandleNavigation} instead */
38
99
  export function handleNavigation(
39
100
  direction: 'up' | 'right' | 'down' | 'left',
40
- ): KeyHandler {
101
+ ): lng.KeyHandler {
41
102
  return function () {
42
- const numChildren = this.children.length;
43
- const wrap = this.wrap;
44
- const lastSelected = this.selected || 0;
103
+ return moveSelection(
104
+ this as lngp.NavigableElement,
105
+ direction === 'up' || direction === 'left' ? -1 : 1,
106
+ );
107
+ };
108
+ }
45
109
 
46
- if (numChildren === 0) {
110
+ /**
111
+ * Handles navigation key events for navigable elements, \
112
+ * such as {@link lngp.Row} and {@link lngp.Column}.
113
+ *
114
+ * Uses {@link moveSelection} to select the next or previous child based on the key pressed.
115
+ *
116
+ * @example
117
+ * ```tsx
118
+ * <view
119
+ * selected={0}
120
+ * onUp={navigableHandleNavigation}
121
+ * onDown={navigableHandleNavigation}
122
+ * onSelectedChanged={(idx, el, child, lastIdx) => {...}}
123
+ * >
124
+ * ```
125
+ */
126
+ export const navigableHandleNavigation: lng.KeyHandler = function (e) {
127
+ return moveSelection(
128
+ this as lngp.NavigableElement,
129
+ e.key === 'ArrowUp' || e.key === 'ArrowLeft' ? -1 : 1,
130
+ );
131
+ };
132
+
133
+ /**
134
+ * Moves the selection within a {@link lngp.NavigableElement}.
135
+ */
136
+ export function moveSelection(
137
+ el: lngp.NavigableElement,
138
+ delta: number,
139
+ ): boolean {
140
+ let selected = findFirstFocusableChildIdx(el, el.selected + delta, delta);
141
+
142
+ if (selected === -1) {
143
+ if (
144
+ !idxInArray(el.selected, el.children) ||
145
+ el.children[el.selected]!.skipFocus ||
146
+ lng.isFocused(el.children[el.selected]!)
147
+ ) {
47
148
  return false;
48
149
  }
150
+ selected = el.selected;
151
+ }
49
152
 
50
- if (direction === 'right' || direction === 'down') {
51
- do {
52
- this.selected = ((this.selected || 0) % numChildren) + 1;
53
- if (this.selected >= numChildren) {
54
- if (!wrap) {
55
- this.selected = -1;
56
- break;
57
- }
58
- this.selected = 0;
59
- }
60
- } while (this.children[this.selected]?.skipFocus);
61
- } else if (direction === 'left' || direction === 'up') {
62
- do {
63
- this.selected = ((this.selected || 0) % numChildren) - 1;
64
- if (this.selected < 0) {
65
- if (!wrap) {
66
- this.selected = -1;
67
- break;
68
- }
69
- this.selected = numChildren - 1;
70
- }
71
- } while (this.children[this.selected]?.skipFocus);
72
- }
153
+ const active = el.children[selected]!;
73
154
 
74
- if (this.selected === -1) {
75
- this.selected = lastSelected;
76
- if (
77
- this.children[this.selected]?.states!.has(
78
- Config.focusStateKey || '$focus',
79
- )
80
- ) {
81
- // This child is already focused, so bubble up to next handler
82
- return false;
155
+ if (el.plinko) {
156
+ // Set the next item to have the same selected index
157
+ // so we move up / down directly
158
+ const lastSelectedChild = el.children[el.selected];
159
+ lng.assertTruthy(lastSelectedChild instanceof lng.ElementNode);
160
+
161
+ const num = lastSelectedChild.selected || 0;
162
+ active.selected =
163
+ num < active.children.length ? num : active.children.length - 1;
164
+ }
165
+
166
+ return selectChild(el, selected);
167
+ }
168
+
169
+ function distanceBetweenRectCenters(a: lng.Rect, b: lng.Rect): number {
170
+ const dx = Math.abs(a.x + a.width / 2 - (b.x + b.width / 2)) / 2;
171
+ const dy = Math.abs(a.y + a.height / 2 - (b.y + b.height / 2)) / 2;
172
+ return Math.sqrt(dx * dx + dy * dy);
173
+ }
174
+
175
+ function findClosestFocusableChildIdx(
176
+ el: lng.ElementNode,
177
+ prevEl: lng.ElementNode,
178
+ ): number {
179
+ // select child closest to the previous active element
180
+ const prevRect = lng.getElementScreenRect(prevEl);
181
+ const elRect = lng.getElementScreenRect(el);
182
+ const childRect: lng.Rect = { x: 0, y: 0, width: 0, height: 0 };
183
+
184
+ let closestIdx = -1;
185
+ let closestDist = Infinity;
186
+
187
+ for (const [idx, child] of el.children.entries()) {
188
+ if (!child.skipFocus) {
189
+ lng.getElementScreenRect(child, el, childRect);
190
+ childRect.x += elRect.x;
191
+ childRect.y += elRect.y;
192
+ const distance = distanceBetweenRectCenters(prevRect, childRect);
193
+ if (distance < closestDist) {
194
+ closestDist = distance;
195
+ closestIdx = idx;
83
196
  }
84
197
  }
85
- const active = this.children[this.selected || 0] || this.children[0];
86
- if (!(active instanceof ElementNode)) return false;
87
- const navigableThis = this as NavigableElement;
88
-
89
- navigableThis.onSelectedChanged &&
90
- navigableThis.onSelectedChanged.call(
91
- navigableThis,
92
- navigableThis.selected,
93
- navigableThis,
94
- active,
95
- lastSelected,
96
- );
97
-
98
- if (this.plinko) {
99
- // Set the next item to have the same selected index
100
- // so we move up / down directly
101
- const lastSelectedChild = this.children[lastSelected];
102
- assertTruthy(lastSelectedChild instanceof ElementNode);
103
- const num = lastSelectedChild.selected || 0;
104
- active.selected =
105
- num < active.children.length ? num : active.children.length - 1;
106
- }
107
- active.setFocus();
108
- return true;
109
- };
198
+ }
199
+
200
+ return closestIdx;
110
201
  }
202
+
203
+ /**
204
+ * Forwards focus to the closest or first focusable child of a {@link lngp.NavigableElement} and
205
+ * selects it.
206
+ *
207
+ * To determine the closest child, it uses the distance between the center of the previous focused element
208
+ * and the center of each child element.
209
+ *
210
+ * @example
211
+ * ```tsx
212
+ * <view
213
+ * selected={0}
214
+ * forwardFocus={spatialForwardFocus}
215
+ * onSelectedChanged={(idx, el, child, lastIdx) => {...}}
216
+ * >
217
+ * ```
218
+ */
219
+ export const spatialForwardFocus: lng.ForwardFocusHandler = function () {
220
+ const prevEl = s.untrack(lng.activeElement);
221
+ if (prevEl) {
222
+ const idx = findClosestFocusableChildIdx(this, prevEl);
223
+ const selected = selectChild(this as lngp.NavigableElement, idx);
224
+ if (selected) return true;
225
+ }
226
+ const idx = findFirstFocusableChildIdx(this as lngp.NavigableElement);
227
+ return selectChild(this as lngp.NavigableElement, idx);
228
+ };
229
+
230
+ /**
231
+ * Handles spatial navigation within a {@link lngp.NavigableElement} by moving focus
232
+ * based on the arrow keys pressed.
233
+ *
234
+ * This function allows for navigation in a grid-like manner for flex-wrap containers, \
235
+ * where pressing the arrow keys will either:
236
+ * - move focus to the next/prev child in the same row/column
237
+ * - or find the closest child in the next/prev row/column.
238
+ *
239
+ * @example
240
+ * ```tsx
241
+ * <view
242
+ * selected={0}
243
+ * display="flex"
244
+ * flexWrap="wrap"
245
+ * onUp={spatialHandleNavigation}
246
+ * onDown={spatialHandleNavigation}
247
+ * onSelectedChanged={(idx, el, child, lastIdx) => {...}}
248
+ * >
249
+ * ```
250
+ */
251
+ export const spatialHandleNavigation: lng.KeyHandler = function (e) {
252
+ let selected = this.selected;
253
+
254
+ if (typeof selected !== 'number' || !idxInArray(selected, this.children)) {
255
+ selected = findFirstFocusableChildIdx(this as lngp.NavigableElement);
256
+ return selectChild(this as lngp.NavigableElement, selected);
257
+ }
258
+
259
+ const prevChild = this.children[selected]!;
260
+
261
+ const move = { x: 0, y: 0 };
262
+ switch (e.key) {
263
+ case 'ArrowLeft':
264
+ move.x = -1;
265
+ break;
266
+ case 'ArrowRight':
267
+ move.x = 1;
268
+ break;
269
+ case 'ArrowUp':
270
+ move.y = -1;
271
+ break;
272
+ case 'ArrowDown':
273
+ move.y = 1;
274
+ break;
275
+ default:
276
+ return false;
277
+ }
278
+
279
+ const flexDir = this.flexDirection === 'column' ? 'y' : 'x';
280
+ const crossDir = flexDir === 'x' ? 'y' : 'x';
281
+ const flexDelta = move[flexDir];
282
+ const crossDelta = move[crossDir];
283
+
284
+ // Select next/prev child in the current column/row
285
+ if (flexDelta !== 0) {
286
+ for (
287
+ let i = selected + flexDelta;
288
+ idxInArray(i, this.children);
289
+ i += flexDelta
290
+ ) {
291
+ const child = this.children[i]!;
292
+ if (child.skipFocus) continue;
293
+
294
+ // Different column/row
295
+ if (child[crossDir] !== prevChild[crossDir]) break;
296
+
297
+ return selectChild(this as lngp.NavigableElement, i);
298
+ }
299
+ }
300
+ // Find child in next/prev column/row
301
+ else {
302
+ let closestIdx = -1;
303
+ let closestDist = Infinity;
304
+
305
+ for (
306
+ let i = selected + crossDelta;
307
+ idxInArray(i, this.children);
308
+ i += crossDelta
309
+ ) {
310
+ const child = this.children[i]!;
311
+ if (child.skipFocus) continue;
312
+
313
+ // Same column/row, skip
314
+ if (child[crossDir] === prevChild[crossDir]) continue;
315
+
316
+ // Different column/row, check distance
317
+ const distance = Math.abs(child[flexDir] - prevChild[flexDir]);
318
+ if (distance >= closestDist) break; // getting further away
319
+
320
+ closestDist = distance;
321
+ closestIdx = i;
322
+ }
323
+
324
+ return selectChild(this as lngp.NavigableElement, closestIdx);
325
+ }
326
+
327
+ return false;
328
+ };
@@ -51,6 +51,7 @@ export function withScrolling(isRow: boolean) {
51
51
  if (
52
52
  !componentRef ||
53
53
  componentRef.scroll === 'none' ||
54
+ selected === lastSelected ||
54
55
  !componentRef.children.length
55
56
  )
56
57
  return;