mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Eliminate workload virtualization scroll gaps
This commit is contained in:
@@ -801,6 +801,15 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
|
||||
already-spaced header. This keeps total table height stable while groups
|
||||
cross the virtual window and prevents the visible estate from bumping by a
|
||||
header row during continuous scrolling.
|
||||
Virtualization must remain visually invisible under rapid wheel and
|
||||
trackpad input. The windowing owner keeps a hysteretic directional runway
|
||||
instead of shifting the slice for every visible-row change, and viewport
|
||||
sync may prewarm that runway from one passive wheel listener before the
|
||||
corresponding scroll event. Group and guest iteration must remain keyed so
|
||||
overlapping rows survive window shifts rather than rebinding every mounted
|
||||
row. This anticipation must preserve the existing bounded mounted-row
|
||||
budget; rendering the whole estate or adding per-row observers, timers, or
|
||||
scroll listeners is forbidden.
|
||||
That same viewport-sync owner may expose one passive app-shell scroll
|
||||
position signal and a smooth back-to-top action for the Workloads surface.
|
||||
The control must remain absent near the top, sit above the mobile navigation
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "d8fe2a935875675b22a87c5471ffc923f7a0ab51",
|
||||
"verified_at": "2026-08-23T16:50:00Z",
|
||||
"base_sha": "dc129cd3a403e9fd2f9177ddbfcf18e5042445e5",
|
||||
"verified_at": "2026-08-23T16:03:35Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx",
|
||||
"frontend-modern/src/features/proxmox/proxmoxPageModel.ts"
|
||||
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx",
|
||||
"frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "5b2dce1ff4cae9624fa106ad9c01f29e0025b33e0def87cee30798e74fe31329",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "27d2077c659d40e2c51472086e745311221d771129455a4bae2be816ab116dbe",
|
||||
"frontend-modern/src/features/proxmox/proxmoxPageModel.ts": "f80b8e01fd31e7f2de55cda5a2f5e58005068b07daa77c3356811ad7f5919051"
|
||||
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "ea56e3d8a4e8c63baf1f99e1dea82d8e6ea2dd801b8eafaf4cb849ebf8612bad",
|
||||
"frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts": "76fd82b5a0baefdf09e37af1713681e4424c2762ece99652b2d998011a457ce8",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts": "0f2315ddf875206f5de6a97fc9a600b5b61aaa2ae910cc4353c5eb78eb5bfd18"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox/overview"
|
||||
@@ -22,22 +22,23 @@
|
||||
"height": 720
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
"width": 375,
|
||||
"height": 812
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Default desktop Proxmox overview renders the 50-node mock estate with 8 clusters and 2 standalone nodes",
|
||||
"Desktop search for pve31 renders one matching node as 1 of 50 and keeps the matching guest inventory visible",
|
||||
"Narrow search for pve31 renders one matching node with the native pve31 identifier beside its friendly name",
|
||||
"Narrow filtered layout has no horizontal overflow and retains the responsive node and workload columns",
|
||||
"The browser verification run reported no console errors"
|
||||
"The large Proxmox mock estate renders 929 guests while retaining the 140-row mounted DOM budget",
|
||||
"After an 8000-pixel desktop wheel burst, 25 workload rows cover the full 1280x720 viewport from -6 to 723 pixels without exposing a spacer",
|
||||
"After a 2800-pixel phone wheel burst, 23 workload rows cover the full 375x812 viewport from -21 to 821 pixels without exposing a spacer",
|
||||
"Desktop scroll height remains 29470 pixels and phone scroll height remains 36372 pixels while virtual windows move",
|
||||
"Rapid-scroll screenshots show continuous populated rows with the back-to-top control remaining above mobile navigation",
|
||||
"The desktop and phone rapid-scroll runs reported no console errors"
|
||||
],
|
||||
"interactions": [
|
||||
"Loaded the Proxmox overview and inspected the default 50-node desktop table",
|
||||
"Entered pve31 in the shared workload search and verified the node table reduced to 1 of 50",
|
||||
"Verified the same query left the matching Analytics A host and its pve31 workload rows visible",
|
||||
"Applied a temporary 390x844 viewport override, reloaded the overview, entered pve31, and verified the narrow DOM and screenshot",
|
||||
"Reset the temporary viewport override after verification"
|
||||
"Loaded the large desktop mock estate and confirmed its initial 140-row virtual runway",
|
||||
"Applied an 8000-pixel wheel burst and immediately measured and captured continuous visible-row coverage",
|
||||
"Applied a temporary 375x812 viewport override and reloaded the 929-guest estate",
|
||||
"Applied a 2800-pixel phone wheel burst and immediately measured and captured continuous visible-row coverage",
|
||||
"Checked runtime error logs, reset the temporary viewport override, and closed the temporary test tab"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createMemo, Index, Show } from 'solid-js';
|
||||
import { createMemo, For, Show } from 'solid-js';
|
||||
|
||||
import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { EnhancedCPUBar } from '@/components/Workloads/EnhancedCPUBar';
|
||||
@@ -373,29 +373,33 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Show>
|
||||
<Index each={props.visibleGroupKeys()} fallback={<></>}>
|
||||
<For each={props.visibleGroupKeys()} fallback={<></>}>
|
||||
{(groupKey) => {
|
||||
const groupGuests = () => props.windowedGroupedGuests()[groupKey()] || [];
|
||||
const fullGroupGuests = () => props.groupedGuests()[groupKey()] || [];
|
||||
const node = () => props.nodeByInstance()[groupKey()];
|
||||
const groupGuests = () => props.windowedGroupedGuests()[groupKey] || [];
|
||||
const fullGroupGuests = () => props.groupedGuests()[groupKey] || [];
|
||||
const groupGuestById = createMemo(
|
||||
() => new Map(groupGuests().map((guest) => [getCanonicalWorkloadId(guest), guest])),
|
||||
);
|
||||
const groupGuestIds = createMemo(() => groupGuests().map(getCanonicalWorkloadId));
|
||||
const node = () => props.nodeByInstance()[groupKey];
|
||||
const groupSummaryScope = createMemo<SummarySeriesGroupScope | null>(() => {
|
||||
if (props.groupingMode() !== 'grouped') {
|
||||
return null;
|
||||
}
|
||||
return buildWorkloadSummaryGroupScope(
|
||||
groupKey(),
|
||||
groupKey,
|
||||
fullGroupGuests(),
|
||||
props.getGroupLabel(groupKey(), fullGroupGuests()),
|
||||
props.getGroupLabel(groupKey, fullGroupGuests()),
|
||||
);
|
||||
});
|
||||
const isSummaryGroupHighlighted = createMemo(
|
||||
() => props.activeSummaryWorkloadGroupScope()?.id === groupKey(),
|
||||
() => props.activeSummaryWorkloadGroupScope()?.id === groupKey,
|
||||
);
|
||||
const shouldShowNodeDrawer = createMemo(
|
||||
() =>
|
||||
props.groupNodeDrawerMode() === 'inline' &&
|
||||
Boolean(node()) &&
|
||||
props.focusedSummaryWorkloadGroupId() === groupKey() &&
|
||||
props.focusedSummaryWorkloadGroupId() === groupKey &&
|
||||
props.selectedGuestId() === null,
|
||||
);
|
||||
const canOpenNodeDrawer = () => props.groupNodeDrawerMode() === 'inline';
|
||||
@@ -438,7 +442,7 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
|
||||
? getInteractiveGroupedTableRowClass()
|
||||
: getGroupedTableRowClass()
|
||||
}
|
||||
data-summary-group-id={groupKey()}
|
||||
data-summary-group-id={groupKey}
|
||||
data-summary-group-series-count={String(
|
||||
groupSummaryScope()?.seriesIds.length ?? 0,
|
||||
)}
|
||||
@@ -451,9 +455,9 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
|
||||
class={getGroupedTableRowCellClass()}
|
||||
>
|
||||
{(() => {
|
||||
const label = props.getGroupLabel(groupKey(), fullGroupGuests());
|
||||
const label = props.getGroupLabel(groupKey, fullGroupGuests());
|
||||
const badges = props.groupLabelBadges();
|
||||
const badge = badges[groupKey()] ?? badges[groupKey().toLowerCase()];
|
||||
const badge = badges[groupKey] ?? badges[groupKey.toLowerCase()];
|
||||
const badgeLabel = badge?.label || label.type;
|
||||
const badgeClass = badge?.classes || GROUPED_TABLE_ROW_BADGE_CLASS;
|
||||
return (
|
||||
@@ -500,7 +504,7 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
|
||||
? 'true'
|
||||
: 'false'
|
||||
: undefined,
|
||||
'data-summary-group-id': groupKey(),
|
||||
'data-summary-group-id': groupKey,
|
||||
'data-summary-group-series-count': String(
|
||||
groupSummaryScope()?.seriesIds.length ?? 0,
|
||||
),
|
||||
@@ -513,7 +517,7 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
|
||||
<Show when={shouldShowNodeDrawer()}>
|
||||
<InlineDetailTableRow
|
||||
colspan={props.totalColumns()}
|
||||
data-inline-node-detail-for={groupKey()}
|
||||
data-inline-node-detail-for={groupKey}
|
||||
>
|
||||
<NodeDrawer
|
||||
node={node()!}
|
||||
@@ -522,9 +526,10 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
|
||||
</InlineDetailTableRow>
|
||||
</Show>
|
||||
</Show>
|
||||
<Index each={groupGuests()} fallback={<></>}>
|
||||
{(guest) => {
|
||||
const guestId = createMemo(() => getCanonicalWorkloadId(guest()));
|
||||
<For each={groupGuestIds()} fallback={<></>}>
|
||||
{(keyedGuestId) => {
|
||||
const guest = () => groupGuestById().get(keyedGuestId)!;
|
||||
const guestId = () => keyedGuestId;
|
||||
const metadataIdCandidates = createMemo(() =>
|
||||
getWorkloadMetadataIdCandidates(guest()),
|
||||
);
|
||||
@@ -606,11 +611,11 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
|
||||
</ComponentErrorBoundary>
|
||||
);
|
||||
}}
|
||||
</Index>
|
||||
</For>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Index>
|
||||
</For>
|
||||
<Show when={props.groupedWindowing.isWindowed() && props.bottomSpacerHeight() > 0}>
|
||||
<TableRow aria-hidden="true" class="h-0 !border-0">
|
||||
<TableCell colspan={props.totalColumns()} class="h-0 !p-0 !border-0 leading-[0]">
|
||||
|
||||
+10
-2
@@ -915,11 +915,18 @@ describe('Workloads performance contract', () => {
|
||||
expect(workloadsWorkloadDerivedStateSource).toContain('guestIndexAtVirtualOffset');
|
||||
expect(workloadsWorkloadDerivedStateSource).toContain('WORKLOADS_TABLE_DIVIDER_HEIGHT');
|
||||
expect(groupedTableWindowingSource).toContain('rowIndexAtOffset');
|
||||
expect(groupedTableWindowingSource).toContain('DEFAULT_EDGE_RUNWAY_ROWS');
|
||||
expect(groupedTableWindowingSource).toContain('viewportIsMounted');
|
||||
expect(workloadPanelSource).toContain('groupGuests()[0] === fullGroupGuests()[0]');
|
||||
expect(workloadPanelSource).toContain('class="h-0 !p-0 !border-0 leading-[0]"');
|
||||
expect(workloadPanelSource).toContain('<For each={props.visibleGroupKeys()}');
|
||||
expect(workloadPanelSource).toContain('<For each={groupGuestIds()}');
|
||||
expect(workloadPanelSource).toContain('groupGuests().map(getCanonicalWorkloadId)');
|
||||
expect(workloadsWorkloadViewportSyncSource).toContain(
|
||||
'SCROLL_TO_TOP_VISIBILITY_THRESHOLD_PX',
|
||||
);
|
||||
expect(workloadsWorkloadViewportSyncSource).toContain("addEventListener('wheel'");
|
||||
expect(workloadsWorkloadViewportSyncSource).toContain('wheelDeltaInPixels');
|
||||
expect(workloadsWorkloadViewportSyncSource).toContain(
|
||||
"scrollContainer.scrollTo({ top: 0, behavior: 'smooth' })",
|
||||
);
|
||||
@@ -1327,7 +1334,8 @@ describe('Workloads performance contract', () => {
|
||||
expect(workloadsStateSource).not.toContain('const DEFAULT_ENABLE_THRESHOLD =');
|
||||
expect(workloadsStateSource).not.toContain('const DEFAULT_OVERSCAN_ROWS =');
|
||||
expect(workloadsSource).not.toContain('createMemo(() => getCanonicalWorkloadId(guest()))');
|
||||
expect(workloadPanelSource).toContain('createMemo(() => getCanonicalWorkloadId(guest()))');
|
||||
expect(workloadPanelSource).toContain('groupGuests().map(getCanonicalWorkloadId)');
|
||||
expect(workloadPanelSource).toContain('groupGuestById().get(keyedGuestId)');
|
||||
expect(workloadPanelSource).toContain('buildWorkloadSummaryGroupScope');
|
||||
expect(workloadPanelSource).toContain('data-summary-group-id');
|
||||
expect(workloadPanelSource).toContain('setHoveredWorkloadGroupScope');
|
||||
@@ -1647,7 +1655,7 @@ describe('Workloads performance contract', () => {
|
||||
expect(workloadTableHeaderSource).not.toContain('NodeGroupHeader');
|
||||
expect(workloadPanelSource).toContain('NodeGroupHeader');
|
||||
expect(workloadPanelSource).toContain('GuestDrawer');
|
||||
expect(workloadPanelSource).toContain('createMemo(() => getCanonicalWorkloadId(guest()))');
|
||||
expect(workloadPanelSource).toContain('groupGuests().map(getCanonicalWorkloadId)');
|
||||
expect(workloadPanelSource).toContain('createSummaryInteractiveRowPreviewHandlers');
|
||||
expect(workloadPanelSource).toContain('resolveSummaryGroupMemberInteractionState');
|
||||
expect(workloadPanelSource).toContain('getInteractiveGroupedTableRowClass');
|
||||
|
||||
+43
-11
@@ -169,7 +169,7 @@ describe('useGroupedTableWindowing', () => {
|
||||
// onScroll
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
describe('onScroll', () => {
|
||||
it('moves window based on scroll position', () => {
|
||||
it('keeps the current window while the viewport has a safe forward runway', () => {
|
||||
const hook = setup({
|
||||
totalRowCount: () => 1000,
|
||||
enabled: () => true,
|
||||
@@ -178,11 +178,42 @@ describe('useGroupedTableWindowing', () => {
|
||||
|
||||
// Scroll down: scrollTop=2000, containerHeight=400, rowHeight=40
|
||||
// firstVisibleRow = floor(2000/40) = 50
|
||||
// overscan = min(20, max(0, 100 - ceil(400/40))) = min(20, max(0,90)) = 20
|
||||
// start = 50 - 20 = 30
|
||||
// The visible range [50, 60) remains inside [0, 100) with 40 rows
|
||||
// ahead, so the window stays mounted instead of churning every row.
|
||||
hook.onScroll(2000, 400, 40);
|
||||
expect(hook.startIndex()).toBe(30);
|
||||
expect(hook.endIndex()).toBe(130);
|
||||
expect(hook.startIndex()).toBe(0);
|
||||
expect(hook.endIndex()).toBe(100);
|
||||
});
|
||||
|
||||
it('moves once the viewport reaches the forward runway and then holds steady', () => {
|
||||
const hook = setup({
|
||||
totalRowCount: () => 1000,
|
||||
enabled: () => true,
|
||||
windowSize: 100,
|
||||
});
|
||||
|
||||
hook.onScroll(3200, 400, 40);
|
||||
expect(hook.startIndex()).toBe(56);
|
||||
expect(hook.endIndex()).toBe(156);
|
||||
|
||||
hook.onScroll(3600, 400, 40);
|
||||
expect(hook.startIndex()).toBe(56);
|
||||
expect(hook.endIndex()).toBe(156);
|
||||
});
|
||||
|
||||
it('keeps most spare rows behind the viewport while scrolling upward', () => {
|
||||
const hook = setup({
|
||||
totalRowCount: () => 1000,
|
||||
enabled: () => true,
|
||||
windowSize: 100,
|
||||
});
|
||||
|
||||
hook.onScroll(8000, 400, 40);
|
||||
expect(hook.startIndex()).toBe(176);
|
||||
|
||||
hook.onScroll(6800, 400, 40);
|
||||
expect(hook.startIndex()).toBe(104);
|
||||
expect(hook.endIndex()).toBe(204);
|
||||
});
|
||||
|
||||
it('clamps start to 0 when scrolled near top', () => {
|
||||
@@ -226,7 +257,7 @@ describe('useGroupedTableWindowing', () => {
|
||||
});
|
||||
// rowHeight=0 → safeRowHeight=40, same calc as normal
|
||||
hook.onScroll(2000, 400, 0);
|
||||
expect(hook.startIndex()).toBe(30);
|
||||
expect(hook.startIndex()).toBe(0);
|
||||
});
|
||||
|
||||
it('handles negative scrollTop gracefully', () => {
|
||||
@@ -265,8 +296,8 @@ describe('useGroupedTableWindowing', () => {
|
||||
hook.onScroll(3200, 400, 32);
|
||||
|
||||
expect(rowIndexAtOffset).toHaveBeenCalledWith(3200, 32);
|
||||
expect(hook.startIndex()).toBe(60);
|
||||
expect(hook.endIndex()).toBe(160);
|
||||
expect(hook.startIndex()).toBe(56);
|
||||
expect(hook.endIndex()).toBe(156);
|
||||
});
|
||||
|
||||
it('falls back to fixed row math when the grouped offset resolver is non-finite', () => {
|
||||
@@ -279,7 +310,7 @@ describe('useGroupedTableWindowing', () => {
|
||||
|
||||
hook.onScroll(2000, 400, 40);
|
||||
|
||||
expect(hook.startIndex()).toBe(30);
|
||||
expect(hook.startIndex()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -490,8 +521,9 @@ describe('useGroupedTableWindowing', () => {
|
||||
enabled: () => true,
|
||||
windowSize: 100,
|
||||
});
|
||||
// Scroll: window starts at 30
|
||||
hook.onScroll(2000, 400, 40);
|
||||
// Reveal a row that centers the window at 30.
|
||||
hook.revealIndex(500);
|
||||
hook.revealIndex(80);
|
||||
// startIndex=30, endIndex=130
|
||||
// Group at index 10..60 → sliceStart = max(0, 30-10) = 20, sliceEnd = min(50, 130-10) = 50
|
||||
const slice = hook.getVisibleSlice('group-partial', guests, 10);
|
||||
|
||||
@@ -66,6 +66,9 @@ describe('useWorkloadViewportSync', () => {
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function), {
|
||||
passive: true,
|
||||
});
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('wheel', expect.any(Function), {
|
||||
passive: true,
|
||||
});
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function));
|
||||
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
@@ -76,6 +79,7 @@ describe('useWorkloadViewportSync', () => {
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('wheel', expect.any(Function));
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function));
|
||||
});
|
||||
|
||||
@@ -169,6 +173,11 @@ describe('useWorkloadViewportSync', () => {
|
||||
|
||||
horizontalTableWrapper.dispatchEvent(new Event('scroll'));
|
||||
expect(onScroll).toHaveBeenCalledTimes(2);
|
||||
|
||||
appScrollContainer.dispatchEvent(new WheelEvent('wheel', { deltaY: 120 }));
|
||||
await waitFor(() => {
|
||||
expect(onScroll).toHaveBeenLastCalledWith(680, 400, 32);
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes one app-shell back-to-top action after sustained workload scrolling', async () => {
|
||||
|
||||
@@ -40,11 +40,13 @@ const DEFAULT_WINDOW_SIZE = 140;
|
||||
// metric-heavy rows is already enough to cause long layout and paint tasks.
|
||||
const DEFAULT_ENABLE_THRESHOLD = 250;
|
||||
const DEFAULT_OVERSCAN_ROWS = 20;
|
||||
const DEFAULT_EDGE_RUNWAY_ROWS = 24;
|
||||
|
||||
export const useGroupedTableWindowing = (
|
||||
options: UseGroupedTableWindowingOptions,
|
||||
): UseGroupedTableWindowingResult => {
|
||||
const [windowStart, setWindowStart] = createSignal(0);
|
||||
let lastFirstVisibleRow = 0;
|
||||
|
||||
const normalizedWindowSize = createMemo(() =>
|
||||
Math.max(1, Math.floor(options.windowSize ?? DEFAULT_WINDOW_SIZE)),
|
||||
@@ -90,10 +92,6 @@ export const useGroupedTableWindowing = (
|
||||
const safeRowHeight = rowHeight > 0 ? rowHeight : 40;
|
||||
const safeContainerHeight = containerHeight > 0 ? containerHeight : safeRowHeight;
|
||||
const rowsInView = Math.max(1, Math.ceil(safeContainerHeight / safeRowHeight));
|
||||
const overscan = Math.min(
|
||||
DEFAULT_OVERSCAN_ROWS,
|
||||
Math.max(0, normalizedWindowSize() - rowsInView),
|
||||
);
|
||||
const resolvedFirstVisibleRow = options.rowIndexAtOffset?.(
|
||||
Math.max(0, scrollTop),
|
||||
safeRowHeight,
|
||||
@@ -101,7 +99,38 @@ export const useGroupedTableWindowing = (
|
||||
const firstVisibleRow = Number.isFinite(resolvedFirstVisibleRow)
|
||||
? Math.max(0, Math.floor(resolvedFirstVisibleRow!))
|
||||
: Math.floor(Math.max(0, scrollTop) / safeRowHeight);
|
||||
setClampedStart(firstVisibleRow - overscan);
|
||||
const visibleEnd = Math.min(options.totalRowCount(), firstVisibleRow + rowsInView);
|
||||
const availableBuffer = Math.max(0, normalizedWindowSize() - rowsInView);
|
||||
const edgeRunway = Math.min(
|
||||
Math.floor(availableBuffer / 2),
|
||||
Math.max(DEFAULT_EDGE_RUNWAY_ROWS, rowsInView),
|
||||
);
|
||||
const direction = Math.sign(firstVisibleRow - lastFirstVisibleRow);
|
||||
lastFirstVisibleRow = firstVisibleRow;
|
||||
|
||||
const leadingRunway = firstVisibleRow - startIndex();
|
||||
const trailingRunway = endIndex() - visibleEnd;
|
||||
const viewportIsMounted = leadingRunway >= 0 && trailingRunway >= 0;
|
||||
if (
|
||||
viewportIsMounted &&
|
||||
((direction > 0 && trailingRunway > edgeRunway) ||
|
||||
(direction < 0 && leadingRunway > edgeRunway) ||
|
||||
(direction === 0 && leadingRunway >= edgeRunway && trailingRunway >= edgeRunway))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep most of the spare window in the active scroll direction. Unlike
|
||||
// tracking every visible row, this runway only moves when the viewport
|
||||
// approaches an edge, avoiding a full reactive slice update per wheel tick.
|
||||
const directionalRunway = Math.max(edgeRunway, availableBuffer - edgeRunway);
|
||||
const rowsBeforeViewport =
|
||||
direction < 0
|
||||
? directionalRunway
|
||||
: direction > 0
|
||||
? edgeRunway
|
||||
: Math.min(DEFAULT_OVERSCAN_ROWS, availableBuffer);
|
||||
setClampedStart(firstVisibleRow - rowsBeforeViewport);
|
||||
};
|
||||
|
||||
const getVisibleSlice = (
|
||||
|
||||
@@ -5,6 +5,13 @@ import type { UseGroupedTableWindowingResult } from './useGroupedTableWindowing'
|
||||
const SCROLLABLE_OVERFLOW_PATTERN = /(?:auto|scroll|overlay)/;
|
||||
const MIN_VERTICAL_SCROLL_RANGE_PX = 1;
|
||||
const SCROLL_TO_TOP_VISIBILITY_THRESHOLD_PX = 640;
|
||||
const WHEEL_LINE_HEIGHT_PX = 16;
|
||||
|
||||
const wheelDeltaInPixels = (event: WheelEvent, viewportHeight: number) => {
|
||||
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) return event.deltaY * WHEEL_LINE_HEIGHT_PX;
|
||||
if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) return event.deltaY * viewportHeight;
|
||||
return event.deltaY;
|
||||
};
|
||||
|
||||
const findScrollContainer = (element: HTMLElement): HTMLElement | null => {
|
||||
let parent = element.parentElement;
|
||||
@@ -35,7 +42,7 @@ interface WorkloadsWorkloadViewportSyncOptions {
|
||||
export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOptions) {
|
||||
const [isScrollToTopVisible, setIsScrollToTopVisible] = createSignal(false);
|
||||
|
||||
const syncGuestWindowToViewport = (measureRows = false) => {
|
||||
const syncGuestWindowToViewport = (measureRows = false, projectedScrollDelta = 0) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const body = options.tableBodyRef();
|
||||
if (!body) return;
|
||||
@@ -60,7 +67,7 @@ export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOp
|
||||
const containerRect = scrollContainer.getBoundingClientRect();
|
||||
const scrollTop = Math.max(0, containerRect.top - rect.top);
|
||||
options.groupedWindowing.onScroll(
|
||||
scrollTop,
|
||||
Math.max(0, scrollTop + projectedScrollDelta),
|
||||
scrollContainer.clientHeight || window.innerHeight,
|
||||
options.rowHeight(),
|
||||
);
|
||||
@@ -70,7 +77,7 @@ export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOp
|
||||
setIsScrollToTopVisible(window.scrollY > SCROLL_TO_TOP_VISIBILITY_THRESHOLD_PX);
|
||||
if (!options.groupedWindowing.isWindowed()) return;
|
||||
options.groupedWindowing.onScroll(
|
||||
Math.max(0, -rect.top),
|
||||
Math.max(0, -rect.top + projectedScrollDelta),
|
||||
window.innerHeight,
|
||||
options.rowHeight(),
|
||||
);
|
||||
@@ -84,6 +91,15 @@ export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOp
|
||||
const handleViewportScroll = () => {
|
||||
syncGuestWindowToViewport();
|
||||
};
|
||||
const handleViewportWheel = (event: Event) => {
|
||||
const wheelEvent = event as WheelEvent;
|
||||
if (!options.groupedWindowing.isWindowed() || wheelEvent.deltaY === 0) return;
|
||||
const viewportHeight =
|
||||
scrollTarget instanceof HTMLElement
|
||||
? scrollTarget.clientHeight || window.innerHeight
|
||||
: window.innerHeight;
|
||||
syncGuestWindowToViewport(false, wheelDeltaInPixels(wheelEvent, viewportHeight));
|
||||
};
|
||||
const handleViewportResize = () => {
|
||||
syncGuestWindowToViewport(true);
|
||||
};
|
||||
@@ -92,9 +108,11 @@ export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOp
|
||||
const scrollContainer = findScrollContainer(options.tableBodyRef()!);
|
||||
const scrollTarget = scrollContainer ?? window;
|
||||
scrollTarget.addEventListener('scroll', handleViewportScroll, { passive: true });
|
||||
scrollTarget.addEventListener('wheel', handleViewportWheel, { passive: true });
|
||||
window.addEventListener('resize', handleViewportResize);
|
||||
onCleanup(() => {
|
||||
scrollTarget.removeEventListener('scroll', handleViewportScroll);
|
||||
scrollTarget.removeEventListener('wheel', handleViewportWheel);
|
||||
window.removeEventListener('resize', handleViewportResize);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user