@octane-xplat/ui 0.7.3 → 0.9.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/README.md +4 -3
- package/dist/native/Button.tsrx.js +1 -1
- package/dist/native/KeyboardAvoiding.mobile.tsrx.js +2 -2
- package/dist/native/Markdown.tsrx.js +233 -0
- package/dist/native/Overlay.tsrx.js +15 -26
- package/dist/native/PinInput.tsrx.js +25 -7
- package/dist/native/Popover.tsrx.js +17 -18
- package/dist/native/Pressable.tsrx.js +4 -1
- package/dist/native/SearchInput.tsrx.js +6 -3
- package/dist/native/Sheet.tsrx.js +22 -37
- package/dist/native/TextArea.tsrx.js +6 -3
- package/dist/native/TextInput.tsrx.js +6 -3
- package/dist/native/VirtualList.tsrx.js +305 -121
- package/dist/native/index.shared.js +7 -5
- package/dist/native/keyboard-inset.mobile.js +5 -5
- package/dist/native/navigation-request.js +26 -0
- package/dist/native/octane-client-build.json +1 -1
- package/dist/native/overlay-lifetime.mobile.js +65 -0
- package/dist/native/pan.tsrx.js +16 -2
- package/dist/native/route-table.js +90 -10
- package/dist/native/route.js +26 -16
- package/dist/native/shared.js +7 -5
- package/dist/native/sheet-service.js +1 -1
- package/dist/native/svg.mobile.js +1 -1
- package/dist/native/text-focus.js +10 -0
- package/dist/native/text-write.js +18 -11
- package/dist/native/toast-anchor.tsrx.js +1 -1
- package/dist/native/ui.js +7 -6
- package/dist/native/vendor/ui-svg/index.android.js +2 -2
- package/dist/native/vendor/ui-svg/index.ios.js +2 -2
- package/dist/native/vendor/ui-svg/{index.common.js → src/index.common.js} +1 -1
- package/dist/native/virtual-list-cell-pool.js +63 -0
- package/dist/web/Button.web.tsrx.js +1 -1
- package/dist/web/KeyboardAvoiding.web.tsrx.js +78 -0
- package/dist/web/Markdown.tsrx.js +98 -0
- package/dist/web/Overlay.web.tsrx.js +81 -32
- package/dist/web/PinInput.web.tsrx.js +24 -10
- package/dist/web/Pressable.web.tsrx.js +169 -53
- package/dist/web/SearchInput.web.tsrx.js +2 -2
- package/dist/web/Sheet.web.tsrx.js +20 -7
- package/dist/web/TextArea.web.tsrx.js +22 -17
- package/dist/web/TextInput.web.tsrx.js +2 -2
- package/dist/web/VirtualList.web.tsrx.js +220 -109
- package/dist/web/WebView.web.tsrx.js +3 -3
- package/dist/web/{index.shared.js → index.shared.web.js} +7 -5
- package/dist/web/modal-focus.web.js +95 -0
- package/dist/web/navigation-request.js +26 -0
- package/dist/web/octane-client-build.json +1 -1
- package/dist/web/pan.web.tsrx.js +27 -10
- package/dist/web/route-table.js +90 -10
- package/dist/web/route.web.js +88 -22
- package/dist/web/sheet-service.web.js +8 -0
- package/dist/web/theme/colorScheme.web.js +31 -4
- package/dist/web/ui.js +8 -6
- package/dist/web/virtual-list-cell-pool.js +63 -0
- package/package.json +21 -19
- package/platforms/android/native-api-usage.json +7 -7
- package/src/CommandPalette.tsrx +1 -0
- package/src/Grid.tsrx +2 -0
- package/src/Icon.macos.tsrx +1 -0
- package/src/KeyboardAvoiding.macos.tsrx +7 -1
- package/src/KeyboardAvoiding.mobile.tsrx +2 -2
- package/src/KeyboardAvoiding.tsrx +15 -0
- package/src/KeyboardAvoiding.web.tsrx +15 -0
- package/src/Markdown.tsrx +121 -0
- package/src/Meter.tsrx +1 -0
- package/src/Overlay.tsrx +15 -30
- package/src/Overlay.web.tsrx +12 -3
- package/src/PinInput.macos.tsrx +20 -6
- package/src/PinInput.mobile.test.ts +107 -0
- package/src/PinInput.tsrx +20 -6
- package/src/PinInput.web.test.tsrx +42 -0
- package/src/PinInput.web.tsrx +20 -6
- package/src/Popover.tsrx +17 -17
- package/src/Pressable.tsrx +1 -1
- package/src/Pressable.web.test.tsrx +36 -0
- package/src/Pressable.web.tsrx +37 -6
- package/src/RouteHost.macos.tsrx +1 -0
- package/src/SearchInput.tsrx +4 -3
- package/src/SearchInput.web.tsrx +1 -1
- package/src/Sheet.tsrx +20 -41
- package/src/Sheet.web.tsrx +9 -1
- package/src/Text.tsrx +2 -0
- package/src/TextArea.tsrx +6 -5
- package/src/TextArea.web.test.tsrx +39 -0
- package/src/TextArea.web.tsrx +7 -5
- package/src/TextInput.tsrx +6 -6
- package/src/TextInput.web.tsrx +1 -1
- package/src/VirtualList.macos.tsrx +93 -6
- package/src/VirtualList.mobile.test.tsrx +232 -0
- package/src/VirtualList.tsrx +285 -81
- package/src/VirtualList.web.tsrx +131 -25
- package/src/WebView.tsrx +1 -0
- package/src/WebView.web.tsrx +3 -0
- package/src/android/BottomNavigationView.android.tsrx +1 -0
- package/src/android/MaterialDialog.android.tsrx +1 -0
- package/src/anim.macos.tsrx +1 -0
- package/src/apply-native-modifiers.native.ts +4 -1
- package/src/child-array.ts +12 -7
- package/src/colorScheme.macos.ts +1 -0
- package/src/controls.macos.tsrx +4 -1
- package/src/data-driver.mobile.test.ts +120 -0
- package/src/data-lifecycle.mobile.test.ts +251 -0
- package/src/escape-props.ts +2 -0
- package/src/grid-auto-placement.ts +54 -12
- package/src/grid-placement.ts +1 -1
- package/src/hosted-unsupported.macos.tsrx +1 -0
- package/src/index.macos.ts +23 -3
- package/src/index.mobile.ts +0 -1
- package/src/index.shared.ts +22 -1
- package/src/index.shared.web.ts +291 -0
- package/src/index.web.ts +1 -1
- package/src/input-readiness.web.test.tsrx +82 -0
- package/src/ios/UITabBar.ios.tsrx +1 -0
- package/src/keyboard-inset.mobile.test.ts +62 -0
- package/src/keyboard-inset.mobile.ts +63 -43
- package/src/layout-shells.macos.tsrx +1 -5
- package/src/macos-extras.macos.tsrx +2 -0
- package/src/modal-focus.web.test.tsrx +63 -0
- package/src/modal-focus.web.ts +162 -0
- package/src/navigation-request.ts +37 -0
- package/src/overlay-lifecycle.mobile.test.ts +316 -0
- package/src/overlay-lifetime.mobile.ts +107 -0
- package/src/pan.mobile.test.tsrx +116 -0
- package/src/pan.tsrx +20 -2
- package/src/pan.web.tsrx +35 -7
- package/src/platformProps.ts +1 -0
- package/src/popover.mobile.test.ts +9 -1
- package/src/pressable-accessibility.mobile.test.ts +30 -0
- package/src/props.ts +163 -3
- package/src/refresh.ts +6 -1
- package/src/refresh.web.test.ts +77 -77
- package/src/route-android.test.ts +162 -0
- package/src/route-history.web.test.ts +80 -0
- package/src/route-navigation.web.test.ts +173 -0
- package/src/route-table.test.ts +188 -9
- package/src/route-table.ts +138 -10
- package/src/route.macos.ts +56 -13
- package/src/route.ts +54 -24
- package/src/route.web.ts +141 -24
- package/src/sheet-detents.ts +5 -3
- package/src/sheet-service.macos.ts +5 -1
- package/src/sheet-service.web.ts +8 -0
- package/src/sheet-snap.test.ts +23 -23
- package/src/sheet-snap.ts +6 -1
- package/src/svg.mobile.ts +9 -3
- package/src/tap-to-blur.ios.ts +48 -32
- package/src/tap-to-blur.mobile.ts +23 -15
- package/src/text-focus.ts +12 -0
- package/src/text-input.mobile.test.ts +89 -0
- package/src/text-write.ts +21 -11
- package/src/theme/colorScheme.linux.ts +2 -4
- package/src/theme/colorScheme.web.ts +51 -4
- package/src/theme/theme-scheme.macos.ts +8 -2
- package/src/theme/tokens.css +1736 -1730
- package/src/translate.macos.ts +4 -1
- package/src/virtual-list-cell-pool.test.ts +53 -0
- package/src/virtual-list-cell-pool.ts +81 -0
- package/src/virtual-list-layout.test.ts +11 -8
- package/src/virtual-list-layout.ts +4 -8
- package/src/windows.linux.ts +7 -2
- package/src/windows.macos.ts +1 -0
- package/types/generated/.tsrx-typegen-manifest.json +13 -3
- package/types/generated/KeyboardAvoiding.d.ts +3 -0
- package/types/generated/KeyboardAvoiding.web.d.ts +2 -0
- package/types/generated/Markdown.d.ts +59 -0
- package/types/generated/TextArea.d.ts +2 -2
- package/types/generated/TextArea.web.d.ts +1 -1
- package/types/generated/TextInput.d.ts +2 -3
- package/types/generated/VirtualList.d.ts +3 -3
- package/types/generated/index.mobile.d.ts +0 -1
- package/types/generated/index.shared.d.ts +6 -3
- package/types/generated/index.shared.web.d.ts +114 -0
- package/types/generated/index.web.d.ts +1 -1
- package/types/generated/modal-focus.web.d.ts +3 -0
- package/types/generated/navigation-request.d.ts +10 -0
- package/types/generated/overlay-lifetime.mobile.d.ts +10 -0
- package/types/generated/props.d.ts +111 -3
- package/types/generated/route-table.d.ts +14 -3
- package/types/generated/svg.mobile.d.ts +2 -2
- package/types/generated/text-focus.d.ts +3 -0
- package/types/generated/text-write.d.ts +3 -1
- package/types/generated/theme/colorScheme.web.d.ts +5 -0
- package/types/generated/virtual-list-cell-pool.d.ts +18 -0
- package/types/index.macos.d.ts +15 -3
- /package/src/vendor/ui-svg/{index.android.ts → src/index.android.ts} +0 -0
- /package/src/vendor/ui-svg/{index.common.ts → src/index.common.ts} +0 -0
- /package/src/vendor/ui-svg/{index.ios.ts → src/index.ios.ts} +0 -0
- /package/src/vendor/ui-svg/{index.ts → src/index.ts} +0 -0
- /package/src/vendor/ui-svg/{typings → src/typings}/SVGKit.d.ts +0 -0
- /package/src/vendor/ui-svg/{typings → src/typings}/android.d.ts +0 -0
- /package/src/vendor/ui-svg/{typings → src/typings}/ios.d.ts +0 -0
- /package/types/generated/vendor/ui-svg/{index.common.d.ts → src/index.common.d.ts} +0 -0
- /package/types/generated/vendor/ui-svg/{index.d.ts → src/index.d.ts} +0 -0
- /package/types/generated/vendor/ui-svg/{index.ios.d.ts → src/index.ios.d.ts} +0 -0
package/src/TextArea.tsrx
CHANGED
|
@@ -4,14 +4,15 @@ import { Utils } from '@nativescript/core';
|
|
|
4
4
|
import type { TextAreaProps } from "./props";
|
|
5
5
|
import { cx } from "./cx";
|
|
6
6
|
import { applyEscapeProps } from "./escape-props";
|
|
7
|
-
import { writeText } from './text-write';
|
|
7
|
+
import { isWritingText, writeText } from './text-write';
|
|
8
|
+
import { blurText } from './text-focus';
|
|
8
9
|
|
|
9
10
|
/** Multiline input — <textview>. TextView measures to content natively, so
|
|
10
11
|
* `autoGrow` is its default; `rows`/`maxRows` have no NS equivalent, so the
|
|
11
12
|
* leaf converts them to min/maxHeight in dips using the widget's own line
|
|
12
13
|
* height (iOS `font.lineHeight`, Android `getLineHeight()` px→dip — measured
|
|
13
|
-
* on `loaded`, re-fit every commit for prop/style changes).
|
|
14
|
-
*
|
|
14
|
+
* on `loaded`, re-fit every commit for prop/style changes). Imperative
|
|
15
|
+
* writes suppress their synchronous textChange echo through text-write. */
|
|
15
16
|
export function TextArea(props: TextAreaProps) @{
|
|
16
17
|
const el = useRef<any>(null);
|
|
17
18
|
const submitOnReturn = props.returnKeyType === 'done' || props.returnKeyType === 'send';
|
|
@@ -46,7 +47,7 @@ export function TextArea(props: TextAreaProps) @{
|
|
|
46
47
|
el.current = v;
|
|
47
48
|
if (!v) return;
|
|
48
49
|
applyEscapeProps(v, props);
|
|
49
|
-
props.bind?.({ focus: () => v.focus(), blur: () =>
|
|
50
|
+
props.bind?.({ focus: () => v.focus(), blur: () => blurText(v), native: v });
|
|
50
51
|
}}
|
|
51
52
|
className={cx('vx-textarea', props.className)}
|
|
52
53
|
style={{ ...props.style, ...(props.placeholderTextColor ? { placeholderColor: props.placeholderTextColor } : {}) }}
|
|
@@ -55,7 +56,7 @@ export function TextArea(props: TextAreaProps) @{
|
|
|
55
56
|
returnKeyType={props.returnKeyType}
|
|
56
57
|
editable={props.editable ?? true}
|
|
57
58
|
onLoaded={(e: any) => fit(e.object)}
|
|
58
|
-
onTextChange={(e: any) => props.onChange?.(e.value)}
|
|
59
|
+
onTextChange={(e: any) => { if (!isWritingText(e.object)) props.onChange?.(e.value); }}
|
|
59
60
|
// TextView emits returnPress for every newline. Only an explicit action
|
|
60
61
|
// key opts into the submit semantic; ordinary Return remains a newline.
|
|
61
62
|
onReturnPress={submitOnReturn ? props.onSubmit : undefined}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
3
|
+
import { act, createRoot } from 'octane'
|
|
4
|
+
import { TextArea } from './TextArea.web.tsrx'
|
|
5
|
+
const roots:any[]=[]
|
|
6
|
+
function mount(props:any={}) {
|
|
7
|
+
const el=document.createElement('div');document.body.append(el);const root=createRoot(el);roots.push(root)
|
|
8
|
+
let measured=20
|
|
9
|
+
const render=(next:any={}) => act(() => root.render(<TextArea {...props} style={{fontSize:10,lineHeight:"10px",padding:0,borderWidth:0,boxSizing:'border-box'}} {...next} />));
|
|
10
|
+
const descriptor=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'scrollHeight')
|
|
11
|
+
Object.defineProperty(HTMLTextAreaElement.prototype,'scrollHeight',{configurable:true,get:() => measured})
|
|
12
|
+
render();const field=el.querySelector('textarea')!
|
|
13
|
+
const input=(value:string,height:number) => {measured=height;field.value=value;act(() => field.dispatchEvent(new Event('input',{bubbles:true})))}
|
|
14
|
+
const measure=(height:number) => {measured=height}
|
|
15
|
+
return {field,render,input,measure,restore:() => {if(descriptor) Object.defineProperty(HTMLTextAreaElement.prototype,'scrollHeight',descriptor);else delete (HTMLTextAreaElement.prototype as any).scrollHeight}}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const restorers:(() => void)[]=[]
|
|
19
|
+
afterEach(() => {for(const root of roots.splice(0)) act(() => root.unmount());for(const restore of restorers.splice(0)) restore();document.body.innerHTML='';vi.restoreAllMocks()})
|
|
20
|
+
it('grows and shrinks an uncontrolled field from actual input', () => {
|
|
21
|
+
const onChange=vi.fn();const m=mount({autoGrow:true,onChange});restorers.push(m.restore)
|
|
22
|
+
m.input('one\ntwo\nthree',80);expect(m.field.style.height).toBe('80px');expect(onChange).toHaveBeenCalledWith('one\ntwo\nthree')
|
|
23
|
+
m.input('one',20);expect(m.field.style.height).toBe('20px');expect(m.field.style.overflowY).toBe('hidden')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('caps uncontrolled growth at maxRows and scrolls past the cap', () => {
|
|
27
|
+
const m=mount({autoGrow:true,rows:2,maxRows:3});restorers.push(m.restore)
|
|
28
|
+
expect(m.field.rows).toBe(2);m.input('many lines',80);expect(m.field.style.height).toBe('30px');expect(m.field.style.overflowY).toBe('auto')
|
|
29
|
+
m.input('short',20);expect(m.field.style.height).toBe('20px');expect(m.field.style.overflowY).toBe('hidden')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('still fits controlled value updates', () => {
|
|
33
|
+
const m=mount({autoGrow:true,value:'one'});restorers.push(m.restore);m.measure(70);m.render({value:'one\ntwo\nthree'})
|
|
34
|
+
expect(m.field.style.height).toBe('70px');expect(m.field.value).toBe('one\ntwo\nthree')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('leaves fixed-row inputs at their declared rows', () => {
|
|
38
|
+
const m=mount({rows:4});restorers.push(m.restore);m.input('many lines',80);expect(m.field.rows).toBe(4);expect(m.field.style.height).toBe('')
|
|
39
|
+
})
|
package/src/TextArea.web.tsrx
CHANGED
|
@@ -4,7 +4,7 @@ import type { TextAreaProps } from "./props";
|
|
|
4
4
|
import { applyEscapeProps } from "./escape-props";
|
|
5
5
|
|
|
6
6
|
/** Multiline input — <textarea>. `autoGrow` re-fits height to content after
|
|
7
|
-
*
|
|
7
|
+
* input and value updates via scrollHeight, capped by `maxRows` translated to a real
|
|
8
8
|
* max-height from the element's computed line-height; past the cap the
|
|
9
9
|
* field scrolls. `useLayoutEffect` so the measure lands pre-paint in the
|
|
10
10
|
* commit that wrote `value`. Deps are explicit — tsrx infers them from the
|
|
@@ -13,7 +13,7 @@ import { applyEscapeProps } from "./escape-props";
|
|
|
13
13
|
export function TextArea(props: TextAreaProps) @{
|
|
14
14
|
const el = useRef<HTMLTextAreaElement | null>(null);
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
const fit = () => {
|
|
17
17
|
const t = el.current;
|
|
18
18
|
if (!t) return;
|
|
19
19
|
const cs = getComputedStyle(t);
|
|
@@ -35,7 +35,9 @@ export function TextArea(props: TextAreaProps) @{
|
|
|
35
35
|
t.style.height = Math.min(need, cap) + 'px';
|
|
36
36
|
t.style.overflowY = need > cap ? 'auto' : 'hidden';
|
|
37
37
|
}
|
|
38
|
-
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
useLayoutEffect(fit, [props.value, props.rows, props.autoGrow, props.maxRows, props.className, props.style]);
|
|
39
41
|
|
|
40
42
|
<textarea
|
|
41
43
|
ref={(node: any) => {
|
|
@@ -53,8 +55,8 @@ export function TextArea(props: TextAreaProps) @{
|
|
|
53
55
|
readOnly={props.editable === false}
|
|
54
56
|
placeholder={props.placeholder}
|
|
55
57
|
rows={props.rows ?? (props.autoGrow ? 1 : 3)}
|
|
56
|
-
onInput={(e: any) => props.onChange?.(e.target.value)}
|
|
57
|
-
onKeyDown={(e: any) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) props.onSubmit?.(); }}
|
|
58
|
+
onInput={(e: any) => { fit(); props.onChange?.(e.target.value); }}
|
|
59
|
+
onKeyDown={(e: any) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !e.isComposing && e.keyCode !== 229) props.onSubmit?.(); }}
|
|
58
60
|
onFocus={props.onFocus}
|
|
59
61
|
onBlur={props.onBlur}
|
|
60
62
|
/>
|
package/src/TextInput.tsrx
CHANGED
|
@@ -3,11 +3,11 @@ import { useEffect, useRef } from 'octane';
|
|
|
3
3
|
import type { TextInputProps } from "./props";
|
|
4
4
|
import { cx } from "./cx";
|
|
5
5
|
import { applyEscapeProps } from "./escape-props";
|
|
6
|
-
import { writeText } from './text-write';
|
|
6
|
+
import { isWritingText, writeText } from './text-write';
|
|
7
|
+
import { blurText } from './text-focus';
|
|
7
8
|
|
|
8
|
-
/** Controlled single-line input.
|
|
9
|
-
*
|
|
10
|
-
* guard needed. `editable` defaults true at the leaf too: an undefined
|
|
9
|
+
/** Controlled single-line input. Imperative writes suppress their synchronous
|
|
10
|
+
* textChange echo through text-write. `editable` defaults true: an undefined
|
|
11
11
|
* write coerces ios.userInteractionEnabled to NO (dead field) — the
|
|
12
12
|
* driver's skip-undefined patch covers it, the ?? stays as documentation.
|
|
13
13
|
* `text` isn't a bound prop: the leaf writes it imperatively so the
|
|
@@ -21,7 +21,7 @@ export function TextInput(props: TextInputProps) @{
|
|
|
21
21
|
el.current = v;
|
|
22
22
|
if (!v) return;
|
|
23
23
|
applyEscapeProps(v, props);
|
|
24
|
-
props.bind?.({ focus: () => v.focus(), blur: () =>
|
|
24
|
+
props.bind?.({ focus: () => v.focus(), blur: () => blurText(v), native: v });
|
|
25
25
|
}}
|
|
26
26
|
id={props.id}
|
|
27
27
|
className={cx('vx-input', props.className)}
|
|
@@ -31,7 +31,7 @@ export function TextInput(props: TextInputProps) @{
|
|
|
31
31
|
keyboardType={props.keyboardType === 'default' ? undefined : props.keyboardType}
|
|
32
32
|
returnKeyType={props.returnKeyType}
|
|
33
33
|
editable={props.editable ?? true}
|
|
34
|
-
onTextChange={(e: any) => props.onChange?.(e.value)}
|
|
34
|
+
onTextChange={(e: any) => { if (!isWritingText(e.object)) props.onChange?.(e.value); }}
|
|
35
35
|
onReturnPress={props.onSubmit}
|
|
36
36
|
onFocus={props.onFocus}
|
|
37
37
|
onBlur={props.onBlur}
|
package/src/TextInput.web.tsrx
CHANGED
|
@@ -21,7 +21,7 @@ export function TextInput(props: TextInputProps) @{
|
|
|
21
21
|
readOnly={props.editable === false}
|
|
22
22
|
placeholder={props.placeholder}
|
|
23
23
|
onInput={(e: any) => props.onChange?.(e.target.value)}
|
|
24
|
-
onKeyDown={(e: any) => { if (e.key === 'Enter' && !e.shiftKey) props.onSubmit?.(); }}
|
|
24
|
+
onKeyDown={(e: any) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) props.onSubmit?.(); }}
|
|
25
25
|
onFocus={props.onFocus}
|
|
26
26
|
onBlur={props.onBlur}
|
|
27
27
|
/>
|
|
@@ -70,6 +70,8 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
|
|
|
70
70
|
const indexByRowKeyRef = useRef(new Map<string, number>())
|
|
71
71
|
const scrollRef = useRef<any>(null)
|
|
72
72
|
const scrollOffsetRef = useRef(0)
|
|
73
|
+
const topSpacerRef = useRef<any>(null)
|
|
74
|
+
const topSpacerHeightRef = useRef(0)
|
|
73
75
|
const viewportHeightRef = useRef(0)
|
|
74
76
|
const viewportWidthRef = useRef(0)
|
|
75
77
|
const headerHeightRef = useRef(0)
|
|
@@ -80,6 +82,12 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
|
|
|
80
82
|
const estimateAnchorRef = useRef<{ rowKey: string; within: number } | null>(null)
|
|
81
83
|
const estimateTimerRef = useRef<any>(null)
|
|
82
84
|
const pendingRowMeasureRef = useRef(new Map<string, number>())
|
|
85
|
+
const programmaticWriteRef = useRef<{ offset: number; until: number } | null>(null)
|
|
86
|
+
/** Convergent deep seek state (FlashList v2's scrollToIndex model): a large
|
|
87
|
+
* external scroll jump is tracked and anchor-restore writes are suppressed
|
|
88
|
+
* while measurements converge — the clip already sits at the seek target,
|
|
89
|
+
* and re-anchoring would rewrite it to the biased estimated prefix. */
|
|
90
|
+
const seekStateRef = useRef<{ target: number; until: number } | null>(null)
|
|
83
91
|
const hasSeparator = Boolean(props.renderSeparator)
|
|
84
92
|
const hasSeparatorRef = useRef(hasSeparator)
|
|
85
93
|
hasSeparatorRef.current = hasSeparator
|
|
@@ -146,8 +154,14 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
|
|
|
146
154
|
return { rowKey: entry.rowKey, within: listOffset - sizeIndexRef.current.prefix(index) }
|
|
147
155
|
}
|
|
148
156
|
|
|
157
|
+
function activeSeek() {
|
|
158
|
+
const seek = seekStateRef.current
|
|
159
|
+
if (seek && Date.now() > seek.until) {seekStateRef.current = null}
|
|
160
|
+
return seekStateRef.current
|
|
161
|
+
}
|
|
162
|
+
|
|
149
163
|
function restoreAnchor(anchor: { rowKey: string; within: number } | null) {
|
|
150
|
-
if (!anchor) {return}
|
|
164
|
+
if (!anchor || activeSeek()) {return}
|
|
151
165
|
queuedAnchorRef.current = anchor
|
|
152
166
|
if (anchorRestoreScheduledRef.current) {return}
|
|
153
167
|
const scroll = scrollRef.current
|
|
@@ -158,6 +172,9 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
|
|
|
158
172
|
if (anchorRestoreTimerRef.current !== timer) {return}
|
|
159
173
|
anchorRestoreTimerRef.current = null
|
|
160
174
|
anchorRestoreScheduledRef.current = false
|
|
175
|
+
// A restore scheduled before a deep seek must not fire its write
|
|
176
|
+
// mid-convergence — it would move the clip off the seek target.
|
|
177
|
+
if (activeSeek()) {queuedAnchorRef.current = null; return}
|
|
161
178
|
if (scrollRef.current !== scroll) {return}
|
|
162
179
|
const queued = queuedAnchorRef.current
|
|
163
180
|
queuedAnchorRef.current = null
|
|
@@ -169,7 +186,8 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
|
|
|
169
186
|
headerHeightRef.current + sizeIndexRef.current.prefix(index) + queued.within,
|
|
170
187
|
)
|
|
171
188
|
|
|
172
|
-
if (sameSize(nextOffset,
|
|
189
|
+
if (sameSize(nextOffset, scrollOffsetRef.current)) {return}
|
|
190
|
+
programmaticWriteRef.current = { offset: nextOffset, until: Date.now() + 250 }
|
|
173
191
|
scrollViewSetTop(scroll, nextOffset)
|
|
174
192
|
}, 0)
|
|
175
193
|
|
|
@@ -184,6 +202,14 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
|
|
|
184
202
|
// relayout/anchor cycle count bounded while samples accumulate.
|
|
185
203
|
estimateTimerRef.current = setTimeout(() => {
|
|
186
204
|
estimateTimerRef.current = null
|
|
205
|
+
// FlashList v2's scrollToIndex model: correction passes stay
|
|
206
|
+
// paused for the whole seek — sizes keep accumulating in the
|
|
207
|
+
// cache/index and one rebuild runs once the seek expires.
|
|
208
|
+
if (activeSeek()) {
|
|
209
|
+
scheduleEstimateRebuild(null)
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
187
213
|
const scheduledAnchor = estimateAnchorRef.current
|
|
188
214
|
estimateAnchorRef.current = null
|
|
189
215
|
rebuildLayout(entriesRef.current, viewportWidthRef.current)
|
|
@@ -207,6 +233,12 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
|
|
|
207
233
|
// re-run this whole path (and reschedule estimate rebuilds) forever.
|
|
208
234
|
if (cached && sameSize(cached.height, height) && sameSize(cached.width, width)) {return}
|
|
209
235
|
|
|
236
|
+
// Keep the seek suppression alive through the measurement storm — the
|
|
237
|
+
// seek ends ~400 ms after the last new/changed size lands (Legend's
|
|
238
|
+
// idle-settle model), not on the first quiet rebuild.
|
|
239
|
+
const seek = activeSeek()
|
|
240
|
+
if (seek) {seek.until = Date.now() + 400}
|
|
241
|
+
|
|
210
242
|
const anchor = queuedAnchorRef.current ?? captureAnchor()
|
|
211
243
|
sizeCacheRef.current.set(cacheKey, { width, height })
|
|
212
244
|
|
|
@@ -237,10 +269,58 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
|
|
|
237
269
|
}
|
|
238
270
|
|
|
239
271
|
function onScroll(event: AppKitScrollEvent) {
|
|
240
|
-
// The event's verticalOffset is the raw clip bounds origin — bottom-up
|
|
241
|
-
// on a non-flipped document — so derive the top-down offset ourselves.
|
|
242
272
|
const scroll = scrollRef.current
|
|
243
|
-
|
|
273
|
+
// Prefer the spacer-landmark offset: the top spacer's bottom edge sits
|
|
274
|
+
// at content position `range.top`, so measuring the clip against it
|
|
275
|
+
// reports DOM-style scrollTop with no docH term. Estimate rebuilds
|
|
276
|
+
// resize the document constantly and would phantom-drift the
|
|
277
|
+
// docHeight-derived offset even when the clip never moved.
|
|
278
|
+
const clip = scroll?.contentView
|
|
279
|
+
const spacerBottomY = Number(topSpacerRef.current?.frame?.origin?.y ?? Number.NaN)
|
|
280
|
+
const doc = scroll?.documentView
|
|
281
|
+
const flip = typeof doc?.isFlipped === 'function' ? doc.isFlipped() : doc?.isFlipped
|
|
282
|
+
const flipped = flip === true || Number(flip) === 1
|
|
283
|
+
const originY = Number(clip?.bounds?.origin?.y ?? event?.verticalOffset ?? 0)
|
|
284
|
+
const clipH = Number(clip?.bounds?.size?.height ?? 0)
|
|
285
|
+
let nextOffset: number
|
|
286
|
+
if (Number.isFinite(spacerBottomY)) {
|
|
287
|
+
const clipTopY = flipped ? originY : originY + clipH
|
|
288
|
+
const spacerBottomContent = flipped
|
|
289
|
+
? Number(topSpacerRef.current?.frame?.size?.height ?? 0) + spacerBottomY
|
|
290
|
+
: spacerBottomY
|
|
291
|
+
|
|
292
|
+
nextOffset =
|
|
293
|
+
headerHeightRef.current +
|
|
294
|
+
topSpacerHeightRef.current +
|
|
295
|
+
(flipped ? clipTopY - spacerBottomContent : spacerBottomY - clipTopY)
|
|
296
|
+
} else {
|
|
297
|
+
nextOffset = scroll ? scrollViewOffset(scroll) : Number(event?.verticalOffset ?? 0)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (props.id) {
|
|
301
|
+
const published = ((globalThis as any).__xplatVlistOffsets ??= {} as Record<string, number>)
|
|
302
|
+
published[props.id] = nextOffset
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let ownWrite = false
|
|
306
|
+
const wrote = programmaticWriteRef.current
|
|
307
|
+
if (wrote !== null) {
|
|
308
|
+
if (sameSize(nextOffset, wrote.offset)) {
|
|
309
|
+
programmaticWriteRef.current = null
|
|
310
|
+
ownWrite = true
|
|
311
|
+
} else if (Date.now() > wrote.until) {
|
|
312
|
+
programmaticWriteRef.current = null
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const jumpThreshold = Math.max(200, viewportHeightRef.current * 1.5)
|
|
317
|
+
// A jump that doesn't match a pending anchor write is an external seek —
|
|
318
|
+
// including one that lands while a stale write flag is still open.
|
|
319
|
+
if (!ownWrite && Math.abs(nextOffset - scrollOffsetRef.current) > jumpThreshold) {
|
|
320
|
+
seekStateRef.current = { target: nextOffset, until: Date.now() + 1000 }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
scrollOffsetRef.current = nextOffset
|
|
244
324
|
viewportHeightRef.current = Number(event?.viewportHeight ?? viewportHeightRef.current)
|
|
245
325
|
refreshWindow()
|
|
246
326
|
}
|
|
@@ -278,6 +358,7 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
|
|
|
278
358
|
|
|
279
359
|
const range = currentRange()
|
|
280
360
|
rangeRef.current = range
|
|
361
|
+
topSpacerHeightRef.current = range.top
|
|
281
362
|
void windowVersion
|
|
282
363
|
const visibleEntries = entries.slice(range.start, range.end)
|
|
283
364
|
|
|
@@ -311,7 +392,13 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
|
|
|
311
392
|
}
|
|
312
393
|
} @else {
|
|
313
394
|
<flexboxlayout flexDirection="column">
|
|
314
|
-
<flexboxlayout
|
|
395
|
+
<flexboxlayout
|
|
396
|
+
className="vx-virtual-list-spacer"
|
|
397
|
+
style={{ height: range.top }}
|
|
398
|
+
ref={(view: any) => {
|
|
399
|
+
topSpacerRef.current = view
|
|
400
|
+
}}
|
|
401
|
+
/>
|
|
315
402
|
@for (const entry of visibleEntries; key entry.rowKey) {
|
|
316
403
|
<flexboxlayout
|
|
317
404
|
flexDirection="column"
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/** @jsxImportSource @nativescript-community/octane */
|
|
2
|
+
import { useState } from 'octane'
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { createObjectContainer, createObjectDriver, createUniversalRoot } from 'octane/universal/native'
|
|
5
|
+
import { VirtualList } from './VirtualList.tsrx'
|
|
6
|
+
import { VirtualListSizeIndex } from './virtual-list-layout'
|
|
7
|
+
|
|
8
|
+
vi.mock('@nativescript/core', () => ({ isAndroid: true, isIOS: false }))
|
|
9
|
+
|
|
10
|
+
const items = Array.from({ length: 500 }, (_, index) => ({ id: 'r' + index }))
|
|
11
|
+
const keyExtractor = (item: { id: string }) => item.id
|
|
12
|
+
const renderItem = (item: { id: string }) => <label id={'row-' + item.id} text={item.id} />
|
|
13
|
+
function StatefulRow(props: { id: string }) @{
|
|
14
|
+
const [taps, setTaps] = useState(0)
|
|
15
|
+
|
|
16
|
+
<label id={'row-' + props.id} text={props.id + ':' + taps} onTap={() => setTaps(taps + 1)} />
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const renderStatefulItem = (item: { id: string }) => <StatefulRow id={item.id} />
|
|
20
|
+
|
|
21
|
+
const roots: { unmount: () => void }[] = []
|
|
22
|
+
|
|
23
|
+
function collect(nodes: any[]): any[] {
|
|
24
|
+
return nodes.flatMap((node) => [node, ...collect(node.children ?? [])])
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function mount(viewportWidth = 100, itemRenderer = renderItem) {
|
|
28
|
+
vi.useFakeTimers()
|
|
29
|
+
const container = createObjectContainer('nativescript')
|
|
30
|
+
const driver = createObjectDriver('nativescript')
|
|
31
|
+
const commits: any[] = []
|
|
32
|
+
const prepare = driver.prepareBatch
|
|
33
|
+
driver.prepareBatch = (...args) => {
|
|
34
|
+
commits.push(args[1])
|
|
35
|
+
return prepare(...args)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const root = createUniversalRoot(container, driver)
|
|
39
|
+
roots.push(root)
|
|
40
|
+
root.render(VirtualList as any, { items, keyExtractor, renderItem: itemRenderer })
|
|
41
|
+
const scroll: any = container.children[0]
|
|
42
|
+
scroll.verticalOffset = 0
|
|
43
|
+
scroll.getActualSize = () => ({ width: viewportWidth, height: 200 })
|
|
44
|
+
scroll.scrollToVerticalOffset = vi.fn((offset) => { scroll.verticalOffset = offset })
|
|
45
|
+
container.dispatchEvent(scroll, 'loaded', { object: scroll })
|
|
46
|
+
const content = scroll.children[0]
|
|
47
|
+
content.getActualSize = () => ({ width: 100, height: 500 * 48 })
|
|
48
|
+
container.dispatchEvent(content, 'layoutchanged', { object: content })
|
|
49
|
+
await vi.advanceTimersByTimeAsync(32)
|
|
50
|
+
scroll.scrollToVerticalOffset.mockClear()
|
|
51
|
+
commits.length = 0
|
|
52
|
+
const move = (offset: number) => {
|
|
53
|
+
scroll.verticalOffset = offset
|
|
54
|
+
container.dispatchEvent(scroll, 'scroll', { object: scroll })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const measure = (id: string, height: number, emit = true) => {
|
|
58
|
+
const row = collect(container.children).find((node) =>
|
|
59
|
+
String(node.props.className).split(' ').includes('vx-virtual-list-row') && collect(node.children).some((child) => child.props.id === id),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if (!row) throw new Error('Missing row ' + id)
|
|
63
|
+
row.getActualSize = () => ({ width: 100, height })
|
|
64
|
+
if (emit) container.dispatchEvent(row, 'layoutchanged', { object: row })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { container, root, scroll, commits, move, measure }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
for (const root of roots.splice(0)) root.unmount()
|
|
72
|
+
vi.useRealTimers()
|
|
73
|
+
vi.unstubAllGlobals()
|
|
74
|
+
vi.restoreAllMocks()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('native VirtualList scroll and measurement scheduling', () => {
|
|
78
|
+
it('does not restore an obsolete anchor after the user advances during measurement', async () => {
|
|
79
|
+
const { scroll, move, measure } = await mount()
|
|
80
|
+
move(480)
|
|
81
|
+
await vi.advanceTimersByTimeAsync(32)
|
|
82
|
+
scroll.scrollToVerticalOffset.mockClear()
|
|
83
|
+
measure('row-r16', 60)
|
|
84
|
+
await Promise.resolve()
|
|
85
|
+
move(492)
|
|
86
|
+
await vi.advanceTimersByTimeAsync(32)
|
|
87
|
+
expect(scroll.verticalOffset).toBe(492)
|
|
88
|
+
expect(scroll.scrollToVerticalOffset).not.toHaveBeenCalled()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('applies an above-anchor height correction while preserving user movement', async () => {
|
|
92
|
+
const { scroll, move, measure } = await mount()
|
|
93
|
+
move(480)
|
|
94
|
+
await vi.advanceTimersByTimeAsync(32)
|
|
95
|
+
measure('row-r5', 60)
|
|
96
|
+
move(492)
|
|
97
|
+
await vi.advanceTimersByTimeAsync(32)
|
|
98
|
+
expect(scroll.verticalOffset).toBe(504)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('coalesces a scroll burst instead of committing inside each native callback', async () => {
|
|
102
|
+
const { commits, move } = await mount()
|
|
103
|
+
move(240)
|
|
104
|
+
move(288)
|
|
105
|
+
move(336)
|
|
106
|
+
expect(commits).toHaveLength(0)
|
|
107
|
+
await vi.advanceTimersByTimeAsync(32)
|
|
108
|
+
expect(commits).toHaveLength(1)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('does not rebuild unvisited estimates when another sample preserves the type average', async () => {
|
|
112
|
+
const reset = vi.spyOn(VirtualListSizeIndex.prototype, 'reset')
|
|
113
|
+
const { measure } = await mount()
|
|
114
|
+
for (let index = 0; index < 4; index++) measure('row-r' + index, 36 + index * 12)
|
|
115
|
+
await vi.advanceTimersByTimeAsync(180)
|
|
116
|
+
const count = reset.mock.calls.length
|
|
117
|
+
for (let index = 4; index < 8; index++) measure('row-r' + index, 36 + (index % 4) * 12)
|
|
118
|
+
await vi.advanceTimersByTimeAsync(180)
|
|
119
|
+
expect(reset).toHaveBeenCalledTimes(count)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('retains exact row sizes when borders make content narrower than the viewport', async () => {
|
|
123
|
+
const { scroll, move, measure } = await mount(102)
|
|
124
|
+
for (let index = 0; index < 4; index++) measure('row-r' + index, 36 + index * 12)
|
|
125
|
+
await vi.advanceTimersByTimeAsync(180)
|
|
126
|
+
move(200)
|
|
127
|
+
await vi.advanceTimersByTimeAsync(32)
|
|
128
|
+
scroll.scrollToVerticalOffset.mockClear()
|
|
129
|
+
measure('row-r0', 36)
|
|
130
|
+
await vi.advanceTimersByTimeAsync(180)
|
|
131
|
+
expect(scroll.verticalOffset).toBe(200)
|
|
132
|
+
expect(scroll.scrollToVerticalOffset).not.toHaveBeenCalled()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('rejects a queued measurement after the keyed item is replaced', async () => {
|
|
136
|
+
const { root, scroll, move, measure } = await mount()
|
|
137
|
+
move(480)
|
|
138
|
+
await vi.advanceTimersByTimeAsync(32)
|
|
139
|
+
measure('row-r5', 120)
|
|
140
|
+
const replacement = items.map((item, index) => index === 5 ? { ...item } : item)
|
|
141
|
+
root.render(VirtualList as any, { items: replacement, keyExtractor, renderItem })
|
|
142
|
+
// The replacement has its own current geometry; the queued 120-height result is obsolete.
|
|
143
|
+
measure('row-r5', 48, false)
|
|
144
|
+
await vi.advanceTimersByTimeAsync(32)
|
|
145
|
+
expect(scroll.verticalOffset).toBe(480)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('commits a burst of row measurements together', async () => {
|
|
149
|
+
const { commits, move, measure } = await mount()
|
|
150
|
+
move(480)
|
|
151
|
+
await vi.advanceTimersByTimeAsync(32)
|
|
152
|
+
commits.length = 0
|
|
153
|
+
measure('row-r12', 60)
|
|
154
|
+
measure('row-r13', 72)
|
|
155
|
+
measure('row-r14', 84)
|
|
156
|
+
expect(commits).toHaveLength(0)
|
|
157
|
+
await vi.advanceTimersByTimeAsync(32)
|
|
158
|
+
expect(commits).toHaveLength(1)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('coalesces through native animation frames and cancels a pending frame on disposal', async () => {
|
|
162
|
+
const { root, commits, move } = await mount()
|
|
163
|
+
vi.stubGlobal('requestAnimationFrame', (callback: (time: number) => void) => setTimeout(() => callback(Date.now()), 8))
|
|
164
|
+
const cancel = vi.fn((id: ReturnType<typeof setTimeout>) => clearTimeout(id))
|
|
165
|
+
vi.stubGlobal('cancelAnimationFrame', cancel)
|
|
166
|
+
move(240)
|
|
167
|
+
move(288)
|
|
168
|
+
expect(commits).toHaveLength(0)
|
|
169
|
+
await vi.advanceTimersByTimeAsync(32)
|
|
170
|
+
expect(commits).toHaveLength(1)
|
|
171
|
+
move(480)
|
|
172
|
+
await vi.advanceTimersByTimeAsync(8)
|
|
173
|
+
root.unmount()
|
|
174
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
175
|
+
expect(cancel).toHaveBeenCalled()
|
|
176
|
+
const count = commits.length
|
|
177
|
+
await vi.advanceTimersByTimeAsync(32)
|
|
178
|
+
expect(commits).toHaveLength(count)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('keeps physical hosts alive on reassignment and destroys them on disposal', async () => {
|
|
182
|
+
const { container, root, move } = await mount()
|
|
183
|
+
const cells = collect(container.children).filter((node) => String(node.props.className).split(' ').includes('vx-virtual-list-cell'))
|
|
184
|
+
expect(cells.length).toBeGreaterThan(0)
|
|
185
|
+
for (const cell of cells) cell.destroyNode = vi.fn()
|
|
186
|
+
move(480)
|
|
187
|
+
await vi.advanceTimersByTimeAsync(32)
|
|
188
|
+
for (const cell of cells) {
|
|
189
|
+
expect(cell.reusable).toBe(true)
|
|
190
|
+
expect(cell.destroyNode).not.toHaveBeenCalled()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
root.unmount()
|
|
194
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
195
|
+
for (const cell of cells) expect(cell.destroyNode).toHaveBeenCalledWith(true)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('retains keyed state across prepend and resets it after leaving the window', async () => {
|
|
199
|
+
const { container, root, scroll, move } = await mount(100, renderStatefulItem)
|
|
200
|
+
move(480)
|
|
201
|
+
await vi.advanceTimersByTimeAsync(32)
|
|
202
|
+
const findRow = () => collect(container.children).find((node) => node.props.id === 'row-r10')
|
|
203
|
+
const row = findRow()
|
|
204
|
+
container.dispatchEvent(row, 'tap', { object: row })
|
|
205
|
+
expect(findRow().props.text).toBe('r10:1')
|
|
206
|
+
const prepended = [{ id: 'prepended' }, ...items]
|
|
207
|
+
root.render(VirtualList as any, { items: prepended, keyExtractor, renderItem: renderStatefulItem })
|
|
208
|
+
await vi.advanceTimersByTimeAsync(32)
|
|
209
|
+
expect(scroll.verticalOffset).toBe(528)
|
|
210
|
+
expect(findRow()).toBe(row)
|
|
211
|
+
expect(findRow().props.text).toBe('r10:1')
|
|
212
|
+
move(4000)
|
|
213
|
+
await vi.advanceTimersByTimeAsync(32)
|
|
214
|
+
expect(findRow()).toBeUndefined()
|
|
215
|
+
for (const visible of collect(container.children).filter((node) => /^row-r/.test(String(node.props.id)))) {
|
|
216
|
+
expect(visible.props.text).toMatch(/:0$/)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
move(528)
|
|
220
|
+
await vi.advanceTimersByTimeAsync(32)
|
|
221
|
+
expect(findRow().props.text).toBe('r10:0')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('cancels queued work when the list is disposed', async () => {
|
|
225
|
+
const { root, commits, move } = await mount()
|
|
226
|
+
move(480)
|
|
227
|
+
root.unmount()
|
|
228
|
+
const count = commits.length
|
|
229
|
+
await vi.advanceTimersByTimeAsync(100)
|
|
230
|
+
expect(commits).toHaveLength(count)
|
|
231
|
+
})
|
|
232
|
+
})
|