@octane-xplat/ui 0.7.2 → 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/CommandPalette.tsrx.js +7 -1
- package/dist/native/Grid.tsrx.js +31 -2
- 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/Pagination.tsrx.js +15 -12
- package/dist/native/PinInput.tsrx.js +25 -7
- package/dist/native/Popover.tsrx.js +33 -23
- package/dist/native/Pressable.tsrx.js +4 -1
- package/dist/native/Screen.tsrx.js +1 -1
- package/dist/native/SearchInput.tsrx.js +6 -3
- package/dist/native/Sheet.tsrx.js +23 -38
- package/dist/native/Slider.tsrx.js +23 -14
- package/dist/native/Text.tsrx.js +16 -0
- 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/WebView.tsrx.js +17 -2
- package/dist/native/_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js +9 -0
- package/dist/native/escape-props.js +2 -0
- package/dist/native/grid-auto-placement.js +83 -0
- package/dist/native/grid-placement.js +20 -0
- 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 +2 -2
- package/dist/native/svg.mobile.js +1 -1
- package/dist/native/{tap-to-blur.mobile.js → tap-to-blur.ios.js} +20 -32
- 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/useMeasure.tsrx.js +17 -18
- 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 +8 -1
- package/src/Grid.tsrx +32 -1
- 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/Pagination.tsrx +6 -4
- 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 +36 -22
- 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/Screen.tsrx +1 -1
- package/src/SearchInput.tsrx +4 -3
- package/src/SearchInput.web.tsrx +1 -1
- package/src/Sheet.tsrx +21 -42
- package/src/Sheet.web.tsrx +9 -1
- package/src/Slider.tsrx +13 -11
- package/src/Text.tsrx +36 -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 +20 -10
- 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 +13 -1
- package/src/grid-auto-placement.ts +163 -0
- package/src/grid-placement.ts +29 -0
- 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.ts +1 -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 +106 -0
- package/src/tap-to-blur.mobile.ts +21 -95
- 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 +1740 -1718
- package/src/translate.macos.ts +4 -1
- package/src/useMeasure.tsrx +20 -21
- 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 +16 -3
- package/types/generated/Grid.d.ts +0 -1
- 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/escape-props.d.ts +4 -0
- package/types/generated/grid-auto-placement.d.ts +13 -0
- package/types/generated/grid-placement.d.ts +5 -0
- 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/tap-to-blur.ios.d.ts +13 -0
- package/types/generated/tap-to-blur.mobile.d.ts +1 -15
- 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/Sheet.tsrx
CHANGED
|
@@ -7,9 +7,10 @@ import type { SheetProps } from './props';
|
|
|
7
7
|
import { cx } from './cx';
|
|
8
8
|
import { applyThemeClasses } from './theme/theme-scheme';
|
|
9
9
|
import { rootLayoutFor } from './root-layout.mobile';
|
|
10
|
+
import { createOverlayLifetime } from './overlay-lifetime.mobile';
|
|
10
11
|
import { attachSheetDetents } from './sheet-detents';
|
|
11
12
|
import { bindBottomInsetToKeyboard } from './keyboard-inset.mobile';
|
|
12
|
-
import { attachTapToBlur } from './tap-to-blur
|
|
13
|
+
import { attachTapToBlur } from './tap-to-blur';
|
|
13
14
|
|
|
14
15
|
function SheetBody(props: { children?: any }) @{
|
|
15
16
|
<>{props.children}</>
|
|
@@ -23,28 +24,16 @@ export function Sheet(props: SheetProps) @{
|
|
|
23
24
|
const shown = useRef<{
|
|
24
25
|
host: GridLayout;
|
|
25
26
|
root: any;
|
|
26
|
-
|
|
27
|
-
unbindKeyboard?: () => void;
|
|
28
|
-
programmatic?: boolean;
|
|
29
|
-
detach?: () => void;
|
|
27
|
+
lifetime: ReturnType<typeof createOverlayLifetime>;
|
|
30
28
|
} | null>(null);
|
|
31
29
|
|
|
30
|
+
const dismiss = useRef(props.onDismiss);
|
|
31
|
+
dismiss.current = props.onDismiss;
|
|
32
|
+
|
|
32
33
|
useEffect(() => {
|
|
33
34
|
const cur = shown.current;
|
|
34
35
|
if (!props.open) {
|
|
35
|
-
|
|
36
|
-
shown.current = null;
|
|
37
|
-
cur.programmatic = true;
|
|
38
|
-
cur.unbindTheme?.();
|
|
39
|
-
cur.unbindKeyboard?.();
|
|
40
|
-
cur.root.unmount?.();
|
|
41
|
-
const owner = cur.host.parent as any;
|
|
42
|
-
if (owner?.hasChild?.(cur.host)) {
|
|
43
|
-
owner.close(cur.host).catch((error: unknown) => {
|
|
44
|
-
console.error('[Sheet] close failed', error);
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
|
|
36
|
+
cur?.lifetime.close();
|
|
48
37
|
return;
|
|
49
38
|
}
|
|
50
39
|
|
|
@@ -63,6 +52,12 @@ export function Sheet(props: SheetProps) @{
|
|
|
63
52
|
if (props.style) Object.assign(host.style, props.style);
|
|
64
53
|
Object.assign(host, isIOS ? props.ios : isAndroid ? props.android : undefined);
|
|
65
54
|
const root = createNativeScriptRoot(host);
|
|
55
|
+
const lifetime = createOverlayLifetime(rl, host, root, 'Sheet', (dismissed) => {
|
|
56
|
+
if (shown.current?.host === host) shown.current = null;
|
|
57
|
+
if (dismissed) dismiss.current?.();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
lifetime.own(unbindTheme);
|
|
66
61
|
// Detents: the controller owns enter/slide-out — RootLayout's open
|
|
67
62
|
// animation always lands translateY at 0, which would fight the
|
|
68
63
|
// detent offsets, so it's skipped while detents are attached.
|
|
@@ -77,23 +72,13 @@ export function Sheet(props: SheetProps) @{
|
|
|
77
72
|
|
|
78
73
|
// Lift the host above the software keyboard (todo 'Add' row inside a
|
|
79
74
|
// demo sheet is bottom-anchored and was covered on iOS).
|
|
80
|
-
|
|
81
|
-
attachTapToBlur(host);
|
|
82
|
-
|
|
83
|
-
shown.current =
|
|
75
|
+
lifetime.own(bindBottomInsetToKeyboard(host));
|
|
76
|
+
lifetime.own(attachTapToBlur(host));
|
|
77
|
+
if (detents) lifetime.own(detents.detach);
|
|
78
|
+
shown.current = { host, root, lifetime };
|
|
84
79
|
root.render(SheetBody as unknown as UniversalComponent, { children: props.children });
|
|
85
|
-
host.on('closed', () => {
|
|
86
|
-
if (shown.current === opened) shown.current = null;
|
|
87
|
-
opened.detach?.();
|
|
88
|
-
opened.unbindKeyboard?.();
|
|
89
|
-
opened.unbindTheme?.();
|
|
90
|
-
opened.root.unmount?.();
|
|
91
|
-
// 'closed' fires for programmatic closes too — onDismiss only when
|
|
92
|
-
// the platform dismissed it (shade tap or detent drag-out).
|
|
93
|
-
if (!opened.programmatic) props.onDismiss?.();
|
|
94
|
-
});
|
|
95
80
|
|
|
96
|
-
|
|
81
|
+
lifetime.open({
|
|
97
82
|
...(props.shadeCover ?? true)
|
|
98
83
|
? { shadeCover: { opacity: 0.4, tapToClose: true } }
|
|
99
84
|
: {},
|
|
@@ -105,17 +90,11 @@ export function Sheet(props: SheetProps) @{
|
|
|
105
90
|
exitTo: { translateY: 400, duration: 200 },
|
|
106
91
|
},
|
|
107
92
|
}),
|
|
108
|
-
})
|
|
109
|
-
.then(() => detents?.enter())
|
|
110
|
-
.catch((error: unknown) => {
|
|
111
|
-
if (shown.current === opened) shown.current = null;
|
|
112
|
-
opened.unbindKeyboard?.();
|
|
113
|
-
opened.unbindTheme?.();
|
|
114
|
-
opened.root.unmount?.();
|
|
115
|
-
console.error('[Sheet] open failed', error);
|
|
116
|
-
});
|
|
93
|
+
}, () => detents?.enter());
|
|
117
94
|
}, [props.open, props.children, props.className, props.style, props.shadeCover, props.ios, props.android]);
|
|
118
95
|
|
|
96
|
+
useEffect(() => () => shown.current?.lifetime.close(), []);
|
|
97
|
+
|
|
119
98
|
// Zero-size sentinel — the sheet resolves its owning RootLayout from the
|
|
120
99
|
// declaring component's ancestry, not getRootLayout() (first-mounted shell).
|
|
121
100
|
<stacklayout ref={(v: any) => { sentinel.current = v; }} style={{ width: 0, height: 0 }} />
|
package/src/Sheet.web.tsrx
CHANGED
|
@@ -4,6 +4,7 @@ import { cx } from './cx';
|
|
|
4
4
|
import { useThemeScheme } from './theme/theme-scheme';
|
|
5
5
|
import { normalizeDetents } from './sheet-snap';
|
|
6
6
|
import { attachSheetDetents } from './sheet-detents.web';
|
|
7
|
+
import { isolateModalFocus } from './modal-focus.web';
|
|
7
8
|
|
|
8
9
|
/** In-window bottom sheet — portal layer with a backdrop and a bottom-
|
|
9
10
|
* anchored panel. Same vocabulary as the native RootLayout sheet.
|
|
@@ -13,8 +14,13 @@ import { attachSheetDetents } from './sheet-detents.web';
|
|
|
13
14
|
export function Sheet(props: SheetProps) @{
|
|
14
15
|
const dark = useThemeScheme() === 'dark';
|
|
15
16
|
const panel = useRef<HTMLElement | null>(null);
|
|
17
|
+
const layer = useRef<HTMLDivElement | null>(null);
|
|
16
18
|
const dismiss = useRef(props.onDismiss);
|
|
17
19
|
dismiss.current = props.onDismiss;
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!props.open || props.shadeCover === false || !layer.current || !panel.current) return;
|
|
22
|
+
return isolateModalFocus(layer.current, panel.current, () => dismiss.current?.());
|
|
23
|
+
}, [props.open, props.shadeCover]);
|
|
18
24
|
|
|
19
25
|
const detents = normalizeDetents(props.detents);
|
|
20
26
|
const maxDetent = detents.length ? detents[detents.length - 1] : 0;
|
|
@@ -31,11 +37,13 @@ export function Sheet(props: SheetProps) @{
|
|
|
31
37
|
|
|
32
38
|
@if (props.open) {
|
|
33
39
|
<>{createPortal(
|
|
34
|
-
<div className={cx('vx-sheet-layer', dark && 'dark')}>
|
|
40
|
+
<div ref={layer} className={cx('vx-sheet-layer', dark && 'dark')}>
|
|
35
41
|
{(props.shadeCover ?? true) && <div className="vx-sheet-backdrop" onClick={props.onDismiss} />}
|
|
36
42
|
<div
|
|
37
43
|
ref={(el: any) => { panel.current = el; }}
|
|
38
44
|
className={cx('vx-sheet', detents.length && 'vx-sheet-detents', props.className)}
|
|
45
|
+
role={props.shadeCover === false ? undefined : 'dialog'}
|
|
46
|
+
aria-modal={props.shadeCover === false ? undefined : true}
|
|
39
47
|
style={{
|
|
40
48
|
...props.style,
|
|
41
49
|
...(detents.length
|
package/src/Slider.tsrx
CHANGED
|
@@ -22,14 +22,20 @@ export function Slider(props: SliderProps) @{
|
|
|
22
22
|
const ratio = Math.min(1, Math.max(0, (value - min) / (max - min)));
|
|
23
23
|
const width = measure.bounds?.width ?? 0;
|
|
24
24
|
|
|
25
|
-
//
|
|
26
|
-
//
|
|
25
|
+
// Keep one ref identity so native measurement listeners stay attached to
|
|
26
|
+
// the same host across rerenders.
|
|
27
27
|
const host = useRef<any>(null);
|
|
28
28
|
const bindHost = useCallback((el: any) => {
|
|
29
29
|
host.current = el;
|
|
30
30
|
measure.bind(el);
|
|
31
31
|
}, []);
|
|
32
32
|
|
|
33
|
+
const trackHeight = 4;
|
|
34
|
+
const thumbSize = 20;
|
|
35
|
+
const trackTop = (28 - trackHeight) / 2;
|
|
36
|
+
const thumbTop = (28 - thumbSize) / 2;
|
|
37
|
+
const thumbLeft = Math.min(Math.max(ratio * width - thumbSize / 2, 0), Math.max(width - thumbSize, 0));
|
|
38
|
+
|
|
33
39
|
useEffect(() => {
|
|
34
40
|
applyNativeProps(host.current, props);
|
|
35
41
|
});
|
|
@@ -52,7 +58,7 @@ export function Slider(props: SliderProps) @{
|
|
|
52
58
|
}
|
|
53
59
|
});
|
|
54
60
|
|
|
55
|
-
<
|
|
61
|
+
<absolutelayout
|
|
56
62
|
id={props.id}
|
|
57
63
|
className={cx('vx-slider', props.className)}
|
|
58
64
|
style={props.style}
|
|
@@ -60,12 +66,8 @@ export function Slider(props: SliderProps) @{
|
|
|
60
66
|
onPan={onPan}
|
|
61
67
|
ref={bindHost}
|
|
62
68
|
>
|
|
63
|
-
<flexboxlayout className="vx-slider-track" />
|
|
64
|
-
<flexboxlayout className="vx-slider-fill"
|
|
65
|
-
<flexboxlayout
|
|
66
|
-
|
|
67
|
-
horizontalAlignment="left"
|
|
68
|
-
style={{ marginLeft: Math.min(Math.max(ratio * width - 10, 0), Math.max(width - 20, 0)) }}
|
|
69
|
-
/>
|
|
70
|
-
</gridlayout>
|
|
69
|
+
<flexboxlayout className="vx-slider-track" style={{ width, translateY: trackTop }} />
|
|
70
|
+
<flexboxlayout className="vx-slider-fill" style={{ width: ratio * width, translateY: trackTop }} />
|
|
71
|
+
<flexboxlayout className="vx-slider-thumb" style={{ translateX: thumbLeft, translateY: thumbTop }} />
|
|
72
|
+
</absolutelayout>
|
|
71
73
|
}
|
package/src/Text.tsrx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** @jsxImportSource @nativescript-community/octane */
|
|
2
2
|
import { createContext, useContext } from 'octane';
|
|
3
|
+
import { Application } from '@nativescript/core';
|
|
3
4
|
import type { TextProps } from "./props";
|
|
4
5
|
import { cx } from "./cx";
|
|
5
6
|
import { applyEscapeProps, nativeAccessibilityRole, nativeAccessibilityState } from "./escape-props";
|
|
@@ -24,8 +25,43 @@ function TextScope(props: { children?: any }) {
|
|
|
24
25
|
|
|
25
26
|
export function Text(props: TextProps) @{
|
|
26
27
|
const inText = useContext(InText);
|
|
28
|
+
const fontWeightValue = String(props.style?.fontWeight ?? '').toLowerCase();
|
|
29
|
+
const namedWeights: Record<string, number> = {
|
|
30
|
+
normal: 400,
|
|
31
|
+
bold: 700,
|
|
32
|
+
thin: 100,
|
|
33
|
+
'extra-light': 200,
|
|
34
|
+
light: 300,
|
|
35
|
+
medium: 500,
|
|
36
|
+
'semi-bold': 600,
|
|
37
|
+
'extra-bold': 800,
|
|
38
|
+
black: 900,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const fontWeight = /^\d+$/.test(fontWeightValue)
|
|
42
|
+
? Number(fontWeightValue)
|
|
43
|
+
: namedWeights[fontWeightValue];
|
|
44
|
+
|
|
45
|
+
const lineHeight = props.style?.lineHeight;
|
|
46
|
+
// UIFontDescriptor resolves variable fonts to the regular axis default on
|
|
47
|
+
// iOS. Set wght explicitly so CSS fontWeight selects the matching instance.
|
|
27
48
|
const textStyle = {
|
|
28
49
|
...props.style,
|
|
50
|
+
...((Application.ios || Application.android) &&
|
|
51
|
+
typeof lineHeight === 'number' &&
|
|
52
|
+
Number.isFinite(lineHeight) &&
|
|
53
|
+
lineHeight > 0 &&
|
|
54
|
+
props.style?.height == null &&
|
|
55
|
+
props.style?.minHeight == null
|
|
56
|
+
? { minHeight: lineHeight }
|
|
57
|
+
: {}),
|
|
58
|
+
...(Application.ios &&
|
|
59
|
+
props.style?.fontVariationSettings == null &&
|
|
60
|
+
fontWeight !== undefined &&
|
|
61
|
+
fontWeight >= 100 &&
|
|
62
|
+
fontWeight <= 900
|
|
63
|
+
? { fontVariationSettings: `'wght' ${fontWeight}` }
|
|
64
|
+
: {}),
|
|
29
65
|
// Clamped text must be allowed to shrink — the shared shrink:0 default
|
|
30
66
|
// would let the label overflow its row instead of truncating.
|
|
31
67
|
...(props.ellipsize || props.numberOfLines ? { flexShrink: 1 } : {}),
|
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"
|