@lightningtv/solid 3.0.0-13 → 3.0.0-15

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