@lightningtv/solid 2.10.5 → 2.10.7

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.
@@ -10,24 +10,22 @@ export type VirtualProps<T> = lng.NewOmit<lngp.RowProps, 'children'> & {
10
10
  bufferSize?: number;
11
11
  wrap?: boolean;
12
12
  scrollIndex?: number;
13
- doScroll?: lngp.Scroller;
14
13
  onEndReached?: () => void;
15
14
  onEndReachedThreshold?: number;
16
- fallback?: s.JSX.Element;
15
+ debugInfo?: boolean;
16
+ factorScale?: boolean;
17
+ uniformSize?: boolean;
17
18
  children: (item: s.Accessor<T>, index: s.Accessor<number>) => s.JSX.Element;
18
19
  };
19
20
 
20
21
  function createVirtual<T>(
21
22
  component: typeof lngp.Row | typeof lngp.Column,
22
23
  props: VirtualProps<T>,
23
- scrollFn: ReturnType<typeof lngp.withScrolling>,
24
24
  keyHandlers: Record<string, lng.KeyHandler>
25
25
  ) {
26
26
  const [cursor, setCursor] = s.createSignal(props.selected ?? 0);
27
27
  const bufferSize = s.createMemo(() => props.bufferSize || 2);
28
- const scrollIndex = s.createMemo(() => {
29
- return props.scrollIndex || 0;
30
- });
28
+ const scrollIndex = s.createMemo(() => props.scrollIndex || 0);
31
29
  const items = s.createMemo(() => props.each || []);
32
30
  const itemCount = s.createMemo(() => items().length);
33
31
  const scrollType = s.createMemo(() => props.scroll || 'auto');
@@ -39,55 +37,270 @@ function createVirtual<T>(
39
37
  return props.selected || 0;
40
38
  };
41
39
 
42
- const start = () => {
43
- if (itemCount() === 0) return 0;
44
- if (props.wrap) {
45
- return utils.mod(cursor() - Math.max(bufferSize(), scrollIndex()), itemCount());
46
- }
47
- if (scrollType() === 'always') {
48
- return Math.min(Math.max(cursor() - bufferSize(), 0), itemCount() - props.displaySize - bufferSize());
49
- }
50
- if (scrollType() === 'auto') {
51
- return utils.clamp(cursor() - Math.max(bufferSize(), scrollIndex()), 0, Math.max(0, itemCount() - props.displaySize - bufferSize()));
52
- }
53
- return utils.clamp(cursor() - bufferSize(), 0, Math.max(0, itemCount() - props.displaySize));
54
- };
40
+ let cachedScaledSize: number | undefined;
41
+ let targetPosition: number | undefined;
42
+ let cachedAnimationController: lng.IAnimationController | undefined;
43
+ const uniformSize = s.createMemo(() => {
44
+ return props.uniformSize !== false;
45
+ });
55
46
 
56
- const end = () => {
57
- if (itemCount() === 0) return 0;
58
- if (props.wrap) {
59
- return (start() + props.displaySize + bufferSize()) % itemCount();
60
- }
61
- return Math.min(itemCount(), start() + props.displaySize + bufferSize());
62
- };
47
+ type SliceState = { start: number; slice: T[]; selected: number, delta: number, shiftBy: number, atStart: boolean };
48
+ const [slice, setSlice] = s.createSignal<SliceState>({
49
+ start: 0,
50
+ slice: [],
51
+ selected: 0,
52
+ delta: 0,
53
+ shiftBy: 0,
54
+ atStart: true,
55
+ });
63
56
 
64
- const getSlice = s.createMemo(() => {
65
- if (itemCount() === 0) return [];
66
- if (!props.wrap) {
67
- return items().slice(start(), end());
57
+ function normalizeDeltaForWindow(delta: number, windowLen: number): number {
58
+ if (!windowLen) return 0;
59
+ const half = windowLen / 2;
60
+ if (delta > half) return delta - windowLen;
61
+ if (delta < -half) return delta + windowLen;
62
+ return delta;
68
63
  }
69
- // Wrapping slice
70
- const sIdx = start();
71
- const eIdx = (sIdx + props.displaySize + bufferSize()) % itemCount();
72
- if (sIdx < eIdx) {
73
- return items().slice(sIdx, eIdx);
64
+
65
+ function computeSize(selected: number = 0) {
66
+ if (uniformSize() && cachedScaledSize) {
67
+ return cachedScaledSize;
68
+ } else if (viewRef) {
69
+ const gap = viewRef.gap || 0;
70
+ const isRow = component === lngp.Row;
71
+ const dimension = isRow ? 'width' : 'height';
72
+ const prevSelectedChild = viewRef.children[selected];
73
+
74
+ if (prevSelectedChild instanceof lng.ElementNode) {
75
+ const itemSize = prevSelectedChild[dimension] || 0;
76
+ const focusStyle = (prevSelectedChild.style?.focus as lng.NodeStyles);
77
+ const scale = (focusStyle?.scale ?? prevSelectedChild.scale ?? 1);
78
+ const scaledSize = itemSize * (props.factorScale ? scale : 1) + gap;
79
+ cachedScaledSize = scaledSize;
80
+ return scaledSize;
81
+ }
82
+ }
83
+ return 0;
74
84
  }
75
- return [...items().slice(sIdx), ...items().slice(0, eIdx)];
76
- });
77
85
 
78
- const [slice, setSlice] = s.createSignal(getSlice());
86
+ function computeSlice(c: number, delta: number, prev: SliceState): SliceState {
87
+ const total = itemCount();
88
+ if (total === 0) return { start: 0, slice: [], selected: 0, delta, shiftBy: 0, atStart: true };
89
+
90
+ const length = props.displaySize + bufferSize();
91
+ let start = prev.start;
92
+ let selected = prev.selected;
93
+ let atStart = prev.atStart;
94
+ let shiftBy = -delta;
95
+
96
+ switch (scrollType()) {
97
+ case 'always':
98
+ if (props.wrap) {
99
+ start = utils.mod(c - 1, total);
100
+ selected = 1;
101
+ } else {
102
+ start = utils.clamp(
103
+ c - bufferSize(),
104
+ 0,
105
+ Math.max(0, total - props.displaySize - bufferSize()),
106
+ );
107
+ if (delta === 0 && c > 3) {
108
+ shiftBy = c < 3 ? -c : -2;
109
+ selected = 2;
110
+ } else {
111
+ selected =
112
+ c < bufferSize()
113
+ ? c
114
+ : c >= total - props.displaySize
115
+ ? c - (total - props.displaySize) + bufferSize()
116
+ : bufferSize();
117
+ }
118
+ }
119
+ break;
120
+
121
+ case 'auto':
122
+ if (props.wrap) {
123
+ if (scrollIndex() && prev.selected < scrollIndex()) {
124
+ start = total - 1;
125
+ selected = Math.max(1, prev.selected + delta);
126
+ } else {
127
+ start = utils.mod(c - (scrollIndex() || 1), total);
128
+ selected = Math.max(1, prev.selected);
129
+ }
130
+ } else {
131
+ if (delta < 0) {
132
+ // Moving left
133
+ if (prev.start > 0 && prev.selected >= props.displaySize) {
134
+ // Move selection left inside slice
135
+ start = prev.start;
136
+ selected = prev.selected - 1;
137
+ } else if (prev.start > 0) {
138
+ // Move selection left inside slice
139
+ start = prev.start - 1;
140
+ selected = prev.selected;
141
+ // shiftBy = 0;
142
+ } else if (prev.start === 0 && !prev.atStart) {
143
+ start = 0;
144
+ selected = prev.selected - 1;
145
+ atStart = true;
146
+ } else if (selected >= props.displaySize - 1) {
147
+ // Shift window left, keep selection pinned
148
+ start = 0;
149
+ selected = prev.selected - 1;
150
+ } else {
151
+ start = 0;
152
+ selected = prev.selected - 1;
153
+ shiftBy = 0;
154
+ }
155
+ } else if (delta > 0) {
156
+ // Moving right
157
+ if (prev.selected < scrollIndex()) {
158
+ // Move selection right inside slice
159
+ start = prev.start;
160
+ selected = prev.selected + 1;
161
+ shiftBy = 0;
162
+ } else if (prev.selected === scrollIndex()) {
163
+ start = prev.start;
164
+ selected = prev.selected + 1;
165
+ atStart = false;
166
+ } else if (prev.start === 0 && prev.selected === 0) {
167
+ start = 0;
168
+ selected = 1;
169
+ atStart = false;
170
+ } else if (prev.start >= total - props.displaySize) {
171
+ // At end: clamp slice, selection drifts right
172
+ start = prev.start;
173
+ selected = c - start;
174
+ shiftBy = 0;
175
+ } else {
176
+ // Shift window right, keep selection pinned
177
+ start = prev.start + 1;
178
+ selected = Math.max(prev.selected, scrollIndex() + 1);;
179
+ }
180
+ } else {
181
+ // Initial setup
182
+ if (c > 0) {
183
+ start = Math.min(c - (scrollIndex() || 1), total - props.displaySize - bufferSize());
184
+ selected = Math.max(scrollIndex() || 1, c - start);
185
+ shiftBy = total - c < 3 ? c - total : -1;
186
+ atStart = false;
187
+ } else {
188
+ start = prev.start;
189
+ selected = prev.selected;
190
+ }
191
+ }
192
+ }
193
+ break;
194
+
195
+ case 'edge':
196
+ const startScrolling = Math.max(1, props.displaySize - 1);
197
+ if (props.wrap) {
198
+ if (delta > 0) {
199
+ if (prev.selected < startScrolling) {
200
+ selected = prev.selected + 1;
201
+ shiftBy = 0;
202
+ } else {
203
+ start = utils.mod(prev.start + 1, total);
204
+ selected = startScrolling;
205
+ }
206
+ } else if (delta < 0) {
207
+ if (prev.selected > 1) {
208
+ selected = prev.selected - 1;
209
+ shiftBy = 0;
210
+ } else {
211
+ start = utils.mod(prev.start - 1, total);
212
+ selected = 1;
213
+ }
214
+ } else {
215
+ start = utils.mod(c - 1, total);
216
+ selected = 1;
217
+ }
218
+ } else {
219
+ if (delta === 0 && c > 0) {
220
+ //initial setup
221
+ selected = c > startScrolling ? startScrolling : c;
222
+ start = Math.max(0, c - startScrolling + 1);
223
+ shiftBy = c > startScrolling ? -1 : 0;
224
+ atStart = c < startScrolling;
225
+ } else if (delta > 0) {
226
+ if (prev.selected < startScrolling - 1) {
227
+ selected = prev.selected + 1;
228
+ shiftBy = 0;
229
+ } else {
230
+ start = prev.start + 1;
231
+ selected = prev.selected;
232
+ atStart = false;
233
+ }
234
+ } else if (delta < 0) {
235
+ if (prev.selected > 1) {
236
+ selected = prev.selected - 1;
237
+ shiftBy = 0;
238
+ } else if (c > 1) {
239
+ start = Math.max(0, c - 1);
240
+ selected = 1;
241
+ } else if (!atStart) {
242
+ start = 0;
243
+ selected = 0;
244
+ atStart = true;
245
+ } else {
246
+ start = 0;
247
+ selected = 0;
248
+ shiftBy = 0;
249
+ atStart = true;
250
+ }
251
+ }
252
+ }
253
+ break;
254
+
255
+ case 'none':
256
+ default:
257
+ start = 0;
258
+ selected = c;
259
+ shiftBy = 0;
260
+ break;
261
+ }
262
+
263
+ let newSlice = prev.slice;
264
+ if (start !== prev.start || newSlice.length === 0) {
265
+ newSlice = props.wrap
266
+ ? Array.from(
267
+ { length },
268
+ (_, i) => items()[utils.mod(start + i, total)],
269
+ ) as T[]
270
+ : items().slice(start, start + length);
271
+ }
272
+
273
+ const state: SliceState = { start, slice: newSlice, selected, delta, shiftBy, atStart };
274
+
275
+ if (props.debugInfo) {
276
+ console.log(`[Virtual]`, {
277
+ cursor: c,
278
+ delta,
279
+ start,
280
+ selected,
281
+ shiftBy,
282
+ slice: state.slice,
283
+ });
284
+ }
285
+
286
+ return state;
287
+ }
79
288
 
80
289
  let viewRef!: lngp.NavigableElement;
81
290
 
82
291
  function scrollToIndex(this: lng.ElementNode, index: number) {
83
292
  if (itemCount() === 0) return;
84
- let target = index;
85
- if (props.wrap) {
86
- target = utils.mod(index, itemCount());
87
- } else {
88
- target = utils.clamp(index, 0, itemCount() - 1);
89
- }
90
- updateSelected([target]);
293
+ updateSelected([utils.clamp(index, 0, itemCount() - 1)]);
294
+ }
295
+
296
+ let lastNavTime = 0;
297
+
298
+ function getAdaptiveDuration(duration: number = 250) {
299
+ const now = performance.now();
300
+ const delta = now - lastNavTime;
301
+ lastNavTime = now;
302
+ if (delta < duration) return delta;
303
+ return duration;
91
304
  }
92
305
 
93
306
  const onSelectedChanged: lngp.OnSelectedChanged = function (_idx, elm, _active, _lastIdx) {
@@ -95,116 +308,108 @@ function createVirtual<T>(
95
308
  let lastIdx = _lastIdx || 0;
96
309
  let active = _active;
97
310
  const initialRun = idx === lastIdx;
311
+ const total = itemCount();
312
+ const isRow = component === lngp.Row;
313
+ const axis = isRow ? 'x' : 'y';
314
+
315
+ if (props.onSelectedChanged) {
316
+ props.onSelectedChanged.call(this as lngp.NavigableElement, idx, this as lngp.NavigableElement, active, lastIdx);
317
+ }
98
318
 
99
319
  if (initialRun && !props.wrap) return;
100
320
 
321
+ const rawDelta = idx - (lastIdx ?? 0);
322
+ const windowLen =
323
+ elm?.children?.length ?? props.displaySize + bufferSize();
324
+ const delta = props.wrap
325
+ ? normalizeDeltaForWindow(rawDelta, windowLen)
326
+ : rawDelta;
327
+
101
328
  if (!initialRun) {
102
- if (props.wrap) {
103
- setCursor(c => utils.mod(c + idx - lastIdx, itemCount()));
104
- } else {
105
- setCursor(c => utils.clamp(c + idx - lastIdx, 0, Math.max(0, itemCount() - 1)));
106
- }
329
+ setCursor(c => {
330
+ const next = c + delta;
331
+ return props.wrap
332
+ ? utils.mod(next, total)
333
+ : utils.clamp(next, 0, total - 1);
334
+ });
107
335
 
108
- setSlice(getSlice());
336
+ const newState = computeSlice(cursor(), delta, slice());
337
+ setSlice(newState);
338
+ elm.selected = newState.selected;
109
339
 
110
- const c = cursor();
111
- const scroll = scrollType();
112
- if (props.wrap) {
113
- this.selected = Math.max(bufferSize(), scrollIndex());
114
- } else if (props.scrollIndex) {
115
- this.selected = Math.min(c, props.scrollIndex);
116
- if (c >= itemCount() - props.displaySize + bufferSize()) {
117
- this.selected = c - (itemCount() - props.displaySize) + bufferSize();
118
- }
119
- } else if (scroll === 'always' || scroll === 'auto') {
120
- if (c < bufferSize()) {
121
- this.selected = c;
122
- } else if (c >= itemCount() - props.displaySize) {
123
- this.selected = c - (itemCount() - props.displaySize) + bufferSize();
124
- } else {
125
- this.selected = bufferSize();
126
- }
127
- }
128
-
129
- if (props.onEndReachedThreshold !== undefined && cursor() >= items().length - props.onEndReachedThreshold) {
340
+ if (
341
+ props.onEndReachedThreshold !== undefined &&
342
+ cursor() >= itemCount() - props.onEndReachedThreshold
343
+ ) {
130
344
  props.onEndReached?.();
131
345
  }
346
+
347
+ if (newState.shiftBy === 0) return;
132
348
  }
133
- const isRow = component === lngp.Row;
134
- const prevChildPos = isRow
135
- ? this.x + active.x
136
- : this.y + active.y;
349
+
350
+ const prevChildPos = (targetPosition ?? this[axis]) + active[axis];
137
351
 
138
352
  queueMicrotask(() => {
139
- this.updateLayout();
140
- if (this._initialPosition === undefined && props.wrap) {
141
- this.offset = 0;
142
- const axis = isRow ? 'x' : 'y';
143
- this._initialPosition = this[axis];
144
- if (scrollIndex() > 0) {
145
- active = this.children[1] as lng.ElementNode;
146
- }
147
- }
148
- if (component === lngp.Row) {
149
- this.lng.x = this._targetPosition = prevChildPos - active.x;
150
- } else {
151
- this.lng.y = this._targetPosition = prevChildPos - active.y;
353
+ elm.updateLayout();
354
+ const childSize = computeSize(slice().selected);
355
+
356
+ if (cachedAnimationController && cachedAnimationController.state === 'running') {
357
+ cachedAnimationController.stop();;
152
358
  }
153
- scrollFn(idx, elm, active, lastIdx);
359
+ this.lng[axis] = prevChildPos - active[axis];
360
+ let offset = this.lng[axis] + (childSize * slice().shiftBy);
361
+ targetPosition = offset;
362
+ cachedAnimationController = this.animate(
363
+ { [axis]: offset },
364
+ { ...this.animationSettings, duration: getAdaptiveDuration(this.animationSettings?.duration)}
365
+ ).start();
154
366
  });
155
367
  };
156
368
 
157
- const chainedOnSelectedChanged = lngp.chainFunctions(props.onSelectedChanged, onSelectedChanged)!;
158
-
159
- const updateSelected = ([selected, _items]: [number?, any?]) => {
160
- if (!viewRef || selected === undefined) return;
161
- const sel = selected;
369
+ const updateSelected = ([sel, _items]: [number?, any?]) => {
370
+ if (!viewRef || sel === undefined || itemCount() === 0) return;
162
371
  const item = items()[sel];
163
- let active = viewRef.children.find(x => x.item === item);
164
- const lastSelected = viewRef.selected;
165
-
166
- if (active instanceof lng.ElementNode) {
167
- viewRef.selected = viewRef.children.indexOf(active);
168
- chainedOnSelectedChanged.call(viewRef, viewRef.selected, viewRef, active, lastSelected);
169
- active.setFocus();
170
- } else {
171
- setCursor(sel);
172
- setSlice(getSlice());
173
- queueMicrotask(() => {
174
- viewRef.updateLayout();
175
- active = viewRef.children.find(x => x.item === item);
176
- if (active instanceof lng.ElementNode) {
177
- viewRef.selected = viewRef.children.indexOf(active);
178
- chainedOnSelectedChanged.call(viewRef, viewRef.selected, viewRef, active, lastSelected);
179
- }
180
- });
181
- }
372
+ setCursor(sel);
373
+ const newState = computeSlice(cursor(), 0, slice());
374
+ setSlice(newState);
375
+
376
+ queueMicrotask(() => {
377
+ viewRef.updateLayout();
378
+ if (slice().shiftBy) {
379
+ const isRow = component === lngp.Row;
380
+ const axis = isRow ? 'x' : 'y';
381
+ const childSize = computeSize(slice().selected);
382
+ viewRef.lng[axis] = viewRef.lng[axis]! + (childSize * slice().shiftBy);
383
+ targetPosition = viewRef.lng[axis];
384
+ }
385
+ let activeIndex = viewRef.children.findIndex(x => x.item === item);
386
+ if (activeIndex === -1) return;
387
+ viewRef.selected = activeIndex;
388
+ viewRef.children[activeIndex]?.setFocus();
389
+ });
182
390
  };
183
391
 
184
- s.createEffect(s.on([() => props.selected, items], updateSelected));
392
+ s.createEffect(s.on([() => props.selected, items], updateSelected, { defer: true }));
185
393
 
186
394
  s.createEffect(s.on(items, () => {
187
- if (!viewRef) return;
395
+ if (!viewRef || itemCount() === 0) return;
188
396
  if (cursor() >= itemCount()) {
189
- setCursor(Math.max(0, itemCount() - 1));
397
+ setCursor(itemCount() - 1);
190
398
  }
191
- setSlice(getSlice());
192
- }, { defer: true }));
399
+ const newState = computeSlice(cursor(), 0, slice());
400
+ setSlice(newState);
401
+ viewRef.selected = newState.selected;
402
+ }));
193
403
 
194
404
  return (<view
195
405
  {...props}
406
+ {...keyHandlers}
196
407
  ref={lngp.chainRefs(el => { viewRef = el as lngp.NavigableElement; }, props.ref)}
197
408
  selected={selected()}
198
409
  cursor={cursor()}
199
- {...keyHandlers}
200
410
  forwardFocus={/* @once */ lngp.navigableForwardFocus}
201
411
  scrollToIndex={/* @once */ scrollToIndex}
202
- onCreate={/* @once */
203
- props.selected
204
- ? lngp.chainFunctions(props.onCreate, scrollFn)
205
- : props.onCreate
206
- }
207
- onSelectedChanged={/* @once */ chainedOnSelectedChanged}
412
+ onSelectedChanged={/* @once */ onSelectedChanged}
208
413
  style={/* @once */ lng.combineStyles(
209
414
  props.style,
210
415
  component === lngp.Row
@@ -221,21 +426,21 @@ function createVirtual<T>(
221
426
  }
222
427
  )}
223
428
  >
224
- <List each={slice()}>{props.children}</List>
429
+ <List each={slice().slice}>{props.children}</List>
225
430
  </view>
226
431
  );
227
432
  }
228
433
 
229
434
  export function VirtualRow<T>(props: VirtualProps<T>) {
230
- return createVirtual(lngp.Row, props, props.doScroll || lngp.withScrolling(true), {
231
- onLeft: lngp.chainFunctions(props.onLeft, lngp.navigableHandleNavigation) as lng.KeyHandler,
232
- onRight: lngp.chainFunctions(props.onRight, lngp.navigableHandleNavigation) as lng.KeyHandler,
435
+ return createVirtual(lngp.Row, props, {
436
+ onLeft: lngp.chainFunctions(props.onLeft, lngp.handleNavigation('left')) as lng.KeyHandler,
437
+ onRight: lngp.chainFunctions(props.onRight, lngp.handleNavigation('right')) as lng.KeyHandler,
233
438
  });
234
439
  }
235
440
 
236
441
  export function VirtualColumn<T>(props: VirtualProps<T>) {
237
- return createVirtual(lngp.Column, props, props.doScroll || lngp.withScrolling(false), {
238
- onUp: lngp.chainFunctions(props.onUp, lngp.navigableHandleNavigation) as lng.KeyHandler,
239
- onDown: lngp.chainFunctions(props.onDown, lngp.navigableHandleNavigation) as lng.KeyHandler,
442
+ return createVirtual(lngp.Column, props, {
443
+ onUp: lngp.chainFunctions(props.onUp, lngp.handleNavigation('up')) as lng.KeyHandler,
444
+ onDown: lngp.chainFunctions(props.onDown, lngp.handleNavigation('down')) as lng.KeyHandler,
240
445
  });
241
446
  }
@@ -24,7 +24,7 @@ function findFirstFocusableChildIdx(
24
24
  i = (i + el.children.length) % el.children.length;
25
25
  } else break;
26
26
  }
27
- if (!el.children[i]!.skipFocus) {
27
+ if (!el.children[i]?.skipFocus) {
28
28
  return i;
29
29
  }
30
30
  }
@@ -95,7 +95,6 @@ export const navigableForwardFocus: lng.ForwardFocusHandler = function () {
95
95
  return selectChild(navigable, selected);
96
96
  };
97
97
 
98
- /** @deprecated Use {@link navigableHandleNavigation} instead */
99
98
  export function handleNavigation(
100
99
  direction: 'up' | 'right' | 'down' | 'left',
101
100
  ): lng.KeyHandler {