Keep virtualized tables on native touch scrolling

This commit is contained in:
rcourtman
2026-08-24 23:23:11 +01:00
parent 5a941e8d3a
commit c00811b6f1
18 changed files with 290 additions and 333 deletions
@@ -38,6 +38,7 @@ captures prefixed with `post-`, and independent Computer Use evidence.
| Loading/empty/error | Page tables use the same presentation and copy slots; domain-specific nouns may vary, but the placement, spacing, iconography, and retry boundary do not. | `sharedPlatformPage.tsx` (`PlatformTableLoading`, `PlatformTableEmpty`, `PlatformTableError`) |
| Status and metrics | Semantic meaning selects the status color; bars share the metric primitive. Different metrics are intentional content, not design variants. | Shared status and metric primitives consumed by platform pages |
| Responsive rows | Rows remain compact and single-line, with truncation and column priority. Tables own overflow; the document does not horizontally scroll. | `Table.tsx` and the exported platform table class constants in `sharedPlatformPage.tsx` |
| Windowed page scrolling | Large tables bound mounted DOM while preserving one browser-native vertical page scroller. Wheel input may prewarm the keyed runway; touch must update it only after the passive native scroll event. | `components/shared/windowedPageScroll.ts`, consumed by `usePlatformWindowedItems.ts`, `useUnifiedResourceTableViewportSync.ts`, `useStoragePoolsTableWindowing.ts`, and `useWorkloadViewportSync.ts` |
## Rendered route coverage
@@ -224,22 +225,35 @@ well.
## Post-audit correction: phone scroll ownership
The initial audit missed a gesture-level inconsistency that was not visible in
static phone captures. On `/docker` at 390×844, the two shared table shells had
2px of incidental vertical overflow. Because `overflow-x: auto` computes the
other axis to `auto` unless it is specified, a vertical gesture beginning over
either table first scrolled that nested shell instead of the page. Proxmox did
not expose the defect because its current table geometry did not create the
same incidental overflow.
static phone captures, and the first correction addressed only one of its two
causes. On `/docker` at 390×844, the shared table shells had 2px of incidental
vertical overflow. Because `overflow-x: auto` computes the other axis to `auto`
unless it is specified, a vertical gesture beginning over either table could
first scroll that nested shell instead of the page. The canonical
`.table-scroll-shell` owner in `frontend-modern/src/index.css` therefore sets
`overflow-y: hidden`: tables own horizontal overflow while the page owns
vertical gestures.
The canonical `.table-scroll-shell` owner in `frontend-modern/src/index.css`
now explicitly sets `overflow-y: hidden`: tables own horizontal overflow while
the page owns vertical gestures. Browser verification at 390×844 confirmed a
gesture over the Docker containers table moved `.app-scroll-shell` to 197px
while both table-shell `scrollTop` values remained zero. The same gesture over
the visible Proxmox node table moved the page to 450px with every table shell
remaining at zero. The shared-primitives guardrail now protects this contract.
Actual touch-device testing then exposed a second interception on large data
sets. The four virtualized table controllers attached a non-passive
`touchmove` listener to the app scroller and replaced the mounted keyed-row
runway before native page movement. Browser scroll anchoring could consume the
gesture: operators saw different table items while the page appeared fixed.
Proxmox Overview currently follows a different workload rendering path, which
explains why the same phone gesture did not show the defect there.
The canonical windowing contract now leaves touch entirely on the
compositor-native path. `components/shared/windowedPageScroll.ts` is the sole
listener-lifecycle owner: it registers passive native `scroll`, desktop wheel
prewarming, and resize, and exposes no touch registration. The four windowing
controllers consume that owner while retaining only their object-specific row
projection. An independent Computer Use swipe over Docker's windowed container
table at 390×844 moved the page from the host summary to the footer while
preserving the table as part of that single page scroll. Focused runtime and
source-contract tests protect the shared owner and every consumer.
Updated visual evidence is stored as
`post-docker-phone-scroll-ownership-390x844.png` alongside the audit captures.
`post-docker-windowed-touch-page-scroll-390x844.jpg` alongside the audit
captures.
## Remaining exceptions
@@ -84,11 +84,14 @@ Estate-sized table and card rendering routes through the shared
`usePlatformWindowedItems` primitives. They preserve the complete filtered and
sorted result plus native scroll extent while bounding mounted DOM to 140 items
on wider layouts and 36 on phones unless a feature declares a smaller budget.
Wheel and vertical touch projection must move a directional keyed-row runway
before native scrolling can expose it. Spacer geometry is structural only: no
feature may present it as loading, pagination, or an intentionally blank data
region. Settings resource pickers, Availability target lists, Actions, alerts,
and every provider-native platform table share this contract.
Wheel projection may prewarm a directional keyed-row runway before native
scrolling exposes it. Touch scrolling must remain compositor-native: windowed
renderers must not attach touch listeners or replace keyed rows before the
browser moves the page, and must update their runway only from the passive
native scroll event. Spacer geometry is structural only: no feature may present
it as loading, pagination, or an intentionally blank data region. Settings
resource pickers, Availability target lists, Actions, alerts, and every
provider-native platform table share this contract.
Shared workload, node, Docker-host, and resource-drawer history presentation
keeps current readings separate from stored samples. A current metric may
@@ -856,12 +856,15 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
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 must prewarm that runway before the compositor advances the viewport.
The single viewport owner may use non-passive wheel and vertical touch-move
listeners for that sequencing, provided they do not cancel native input or
add per-frame reactive work when the mounted runway already covers the next
viewport. Group and guest iteration must remain keyed so overlapping rows
instead of shifting the slice for every visible-row change, and wheel
viewport sync may prewarm that runway before the compositor advances the
viewport. Every windowed surface must route listener lifecycle, scroll-
ancestor selection, and wheel-delta normalization through
`frontend-modern/src/components/shared/windowedPageScroll.ts`. That shared
owner may register a non-passive wheel listener, but it must not register a
touch listener: phone gestures remain compositor-native and advance the
keyed runway only through the passive native scroll event after the page
moves. 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
@@ -5157,9 +5157,13 @@ the Storage tab when the storage adapter cannot consume it. Pool filtering,
sorting, grouping, counts, and scroll height continue to describe the complete
result set, while `useStoragePoolsTableWindowing.ts` flattens the expanded group
model and mounts at most 72 pool/header items with table spacers. Wheel and
touch projection must move that bounded window before native scrolling so a
fast scroll does not expose an unpainted table region. Expanded pool targets
must be revealed inside the current window. The page-scoped
touch input intentionally follow different canonical paths: wheel projection
may prewarm that bounded window, while touch must remain compositor-native and
advance the window only from the passive native page-scroll event. The
listener lifecycle, scroll-ancestor selection, and wheel normalization must
route through `frontend-modern/src/components/shared/windowedPageScroll.ts` so
Storage cannot reintroduce a competing phone gesture owner. Expanded pool
targets must be revealed inside the current window. The page-scoped
`/api/storage-charts` summary remains the sole pool-growth history payload, but
its initial cache read, parse, and fetch may wait for browser idle so those
secondary growth readouts cannot block the first Storage frame.
@@ -3339,6 +3339,12 @@ and viewport reveal plus scroll synchronization now route through
`frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts`,
so the shared consumer model is no longer interleaving selector derivation,
layout policy, and DOM viewport coordination inside one mixed state boundary.
That viewport controller must consume
`frontend-modern/src/components/shared/windowedPageScroll.ts` for scroll-
ancestor selection, wheel normalization, and listener lifecycle. Wheel may
prewarm the bounded row runway, but touch remains compositor-native and updates
the runway only through the passive native page-scroll event; unified-resource
tables must not register their own touch listener.
The mobile shell class from that shared state model now uses `min-w-full` for
the phone stage and restores the existing wider-stage floor above it. Mobile
column weights must sum to the available table width, retain the prioritized
@@ -762,9 +762,10 @@ describe('UnifiedResourceTable performance contract', () => {
expect(unifiedResourceTableStateSource).not.toContain('const items: HostTableItem[] = [];');
expect(unifiedResourceTableStateSource).not.toContain('window.addEventListener');
expect(unifiedResourceTableStateSource).not.toContain('getBoundingClientRect');
expect(unifiedResourceTableViewportSyncSource).toContain('window.addEventListener');
expect(unifiedResourceTableViewportSyncSource).toContain('bindWindowedPageScrollEvents');
expect(unifiedResourceTableViewportSyncSource).toContain('getBoundingClientRect');
expect(unifiedResourceTableViewportSyncSource).not.toContain('scrollIntoView');
expect(unifiedResourceTableViewportSyncSource).not.toContain("addEventListener('touch");
expect(unifiedResourceTableViewportSyncSource).toContain('hostWindowing.onScroll');
expect(unifiedResourceTableModelSource).toContain('export const getPBSTableRow');
expect(unifiedResourceTableModelSource).toContain('export const getPMGTableRow');
@@ -1,31 +1,11 @@
import { createEffect, createSignal, onCleanup, untrack, type Accessor } from 'solid-js';
import {
bindWindowedPageScrollEvents,
findWindowedPageScrollContainer,
wheelDeltaInPixels,
} from '@/components/shared/windowedPageScroll';
import { useTableWindowing } from './useTableWindowing';
const SCROLLABLE_OVERFLOW_PATTERN = /(?:auto|scroll|overlay)/;
const WHEEL_LINE_HEIGHT_PX = 16;
const findScrollContainer = (element: HTMLElement): HTMLElement | null => {
let parent = element.parentElement;
while (parent && parent !== document.body && parent !== document.documentElement) {
const styles = getComputedStyle(parent);
const hasVerticalScrollRange = parent.scrollHeight - parent.clientHeight > 1;
if (
SCROLLABLE_OVERFLOW_PATTERN.test(styles.overflowY) &&
(styles.overflowY === 'scroll' || hasVerticalScrollRange)
) {
return parent;
}
parent = parent.parentElement;
}
return null;
};
const wheelDeltaInPixels = (event: WheelEvent, viewportHeight: number): 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;
};
interface UseUnifiedResourceTableViewportSyncOptions {
totalCount: Accessor<number>;
estimatedRowHeight: number;
@@ -43,7 +23,7 @@ export function useUnifiedResourceTableViewportSync(
const body = hostBodyRef();
if (!body) return;
const rect = body.getBoundingClientRect();
const scrollContainer = findScrollContainer(body);
const scrollContainer = findWindowedPageScrollContainer(body);
if (scrollContainer) {
const containerRect = scrollContainer.getBoundingClientRect();
hostWindowing.onScroll(
@@ -66,9 +46,8 @@ export function useUnifiedResourceTableViewportSync(
if (!hostWindowing.isWindowed()) return;
if (!hostBodyRef()) return;
let lastTouchY: number | null = null;
const body = hostBodyRef()!;
const scrollContainer = findScrollContainer(body);
const scrollContainer = findWindowedPageScrollContainer(body);
const scrollTarget: HTMLElement | Window = scrollContainer ?? window;
const viewportHeight = () => scrollContainer?.clientHeight || window.innerHeight;
const handleViewportChange = () => syncHostWindowToViewport();
@@ -77,44 +56,19 @@ export function useUnifiedResourceTableViewportSync(
if (wheelEvent.deltaY === 0) return;
syncHostWindowToViewport(wheelDeltaInPixels(wheelEvent, viewportHeight()));
};
const handleTouchStart = (event: Event) => {
lastTouchY = (event as TouchEvent).touches.item(0)?.clientY ?? null;
};
const handleTouchMove = (event: Event) => {
const nextTouchY = (event as TouchEvent).touches.item(0)?.clientY ?? null;
if (nextTouchY == null || lastTouchY == null) {
lastTouchY = nextTouchY;
return;
}
const deltaY = lastTouchY - nextTouchY;
lastTouchY = nextTouchY;
if (deltaY !== 0) syncHostWindowToViewport(deltaY);
};
const handleTouchEnd = () => {
lastTouchY = null;
};
// The initial measurement reads the current window bounds and may move the
// window. Keep those reads outside this setup effect's dependency graph;
// otherwise a far-away reveal target and the viewport can repeatedly move
// the window back and forth until Solid exhausts the call stack.
untrack(handleViewportChange);
scrollTarget.addEventListener('scroll', handleViewportChange, { passive: true });
scrollTarget.addEventListener('wheel', handleWheel, { passive: false });
scrollTarget.addEventListener('touchstart', handleTouchStart, { passive: true });
scrollTarget.addEventListener('touchmove', handleTouchMove, { passive: false });
scrollTarget.addEventListener('touchend', handleTouchEnd, { passive: true });
scrollTarget.addEventListener('touchcancel', handleTouchEnd, { passive: true });
window.addEventListener('resize', handleViewportChange);
onCleanup(() => {
scrollTarget.removeEventListener('scroll', handleViewportChange);
scrollTarget.removeEventListener('wheel', handleWheel);
scrollTarget.removeEventListener('touchstart', handleTouchStart);
scrollTarget.removeEventListener('touchmove', handleTouchMove);
scrollTarget.removeEventListener('touchend', handleTouchEnd);
scrollTarget.removeEventListener('touchcancel', handleTouchEnd);
window.removeEventListener('resize', handleViewportChange);
});
onCleanup(
bindWindowedPageScrollEvents({
scrollTarget,
onScroll: handleViewportChange,
onWheel: handleWheel,
onResize: handleViewportChange,
}),
);
});
return {
@@ -8,6 +8,7 @@ import {
buildStoragePoolsTableItems,
useStoragePoolsTableWindowing,
} from '@/components/Storage/useStoragePoolsTableWindowing';
import storagePoolsTableWindowingSource from '@/components/Storage/useStoragePoolsTableWindowing.ts?raw';
const makeRecord = (index: number): StorageRecord =>
({
@@ -33,6 +34,11 @@ const makeGroup = (
});
describe('useStoragePoolsTableWindowing', () => {
it('keeps phone touch scrolling on the browser-native page path', () => {
expect(storagePoolsTableWindowingSource).toContain('bindWindowedPageScrollEvents');
expect(storagePoolsTableWindowingSource).not.toContain("addEventListener('touch");
});
it('flattens only visible group headers and expanded pool rows', () => {
const items = buildStoragePoolsTableItems([
makeGroup('open', [makeRecord(1), makeRecord(2)]),
@@ -1,14 +1,17 @@
import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from 'solid-js';
import { useTableWindowing } from '@/components/Infrastructure/useTableWindowing';
import {
bindWindowedPageScrollEvents,
findWindowedPageScrollContainer,
wheelDeltaInPixels,
} from '@/components/shared/windowedPageScroll';
import type { StorageRecord } from '@/features/storageBackups/models';
import type { StoragePoolsTableGroupModel } from '@/features/storageBackups/storagePoolsTablePresentation';
const STORAGE_POOL_WINDOW_SIZE = 72;
const STORAGE_POOL_ESTIMATED_ROW_HEIGHT = 32;
const STORAGE_POOL_TABLE_DIVIDER_HEIGHT = 1;
const SCROLLABLE_OVERFLOW_PATTERN = /(?:auto|scroll|overlay)/;
const WHEEL_LINE_HEIGHT_PX = 16;
type StoragePoolsTableGroupItem = {
kind: 'group';
@@ -41,28 +44,6 @@ export const buildStoragePoolsTableItems = (
return items;
};
const findScrollContainer = (element: HTMLElement): HTMLElement | null => {
let parent = element.parentElement;
while (parent && parent !== document.body && parent !== document.documentElement) {
const styles = getComputedStyle(parent);
const hasVerticalScrollRange = parent.scrollHeight - parent.clientHeight > 1;
if (
SCROLLABLE_OVERFLOW_PATTERN.test(styles.overflowY) &&
(styles.overflowY === 'scroll' || hasVerticalScrollRange)
) {
return parent;
}
parent = parent.parentElement;
}
return null;
};
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;
};
type UseStoragePoolsTableWindowingOptions = {
groups: Accessor<readonly StoragePoolsTableGroupModel[]>;
expandedPoolId: Accessor<string | null>;
@@ -118,7 +99,7 @@ export const useStoragePoolsTableWindowing = (options: UseStoragePoolsTableWindo
}
const bodyRect = body.getBoundingClientRect();
const scrollContainer = findScrollContainer(body);
const scrollContainer = findWindowedPageScrollContainer(body);
if (scrollContainer) {
const containerRect = scrollContainer.getBoundingClientRect();
windowing.onScroll(
@@ -141,8 +122,7 @@ export const useStoragePoolsTableWindowing = (options: UseStoragePoolsTableWindo
const body = bodyRef();
if (!body || !windowing.isWindowed()) return;
let lastTouchY: number | null = null;
const scrollContainer = findScrollContainer(body);
const scrollContainer = findWindowedPageScrollContainer(body);
const scrollTarget: HTMLElement | Window = scrollContainer ?? window;
const viewportHeight = () =>
scrollContainer?.clientHeight || window.innerHeight || estimatedRowHeight();
@@ -152,42 +132,17 @@ export const useStoragePoolsTableWindowing = (options: UseStoragePoolsTableWindo
if (wheelEvent.deltaY === 0) return;
syncWindowToViewport(wheelDeltaInPixels(wheelEvent, viewportHeight()));
};
const handleTouchStart = (event: Event) => {
lastTouchY = (event as TouchEvent).touches.item(0)?.clientY ?? null;
};
const handleTouchMove = (event: Event) => {
const nextTouchY = (event as TouchEvent).touches.item(0)?.clientY ?? null;
if (nextTouchY == null || lastTouchY == null) {
lastTouchY = nextTouchY;
return;
}
const deltaY = lastTouchY - nextTouchY;
lastTouchY = nextTouchY;
if (deltaY !== 0) syncWindowToViewport(deltaY);
};
const handleTouchEnd = () => {
lastTouchY = null;
};
const handleResize = () => syncWindowToViewport(0, true);
handleResize();
scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
// Pre-position the bounded row window before native scrolling advances the viewport.
scrollTarget.addEventListener('wheel', handleWheel, { passive: false });
scrollTarget.addEventListener('touchstart', handleTouchStart, { passive: true });
scrollTarget.addEventListener('touchmove', handleTouchMove, { passive: false });
scrollTarget.addEventListener('touchend', handleTouchEnd, { passive: true });
scrollTarget.addEventListener('touchcancel', handleTouchEnd, { passive: true });
window.addEventListener('resize', handleResize);
onCleanup(() => {
scrollTarget.removeEventListener('scroll', handleScroll);
scrollTarget.removeEventListener('wheel', handleWheel);
scrollTarget.removeEventListener('touchstart', handleTouchStart);
scrollTarget.removeEventListener('touchmove', handleTouchMove);
scrollTarget.removeEventListener('touchend', handleTouchEnd);
scrollTarget.removeEventListener('touchcancel', handleTouchEnd);
window.removeEventListener('resize', handleResize);
});
onCleanup(
bindWindowedPageScrollEvents({
scrollTarget,
onScroll: handleScroll,
onWheel: handleWheel,
onResize: handleResize,
}),
);
});
return {
@@ -926,12 +926,8 @@ describe('Workloads performance contract', () => {
expect(workloadsWorkloadViewportSyncSource).toContain(
'SCROLL_TO_TOP_VISIBILITY_THRESHOLD_PX',
);
expect(workloadsWorkloadViewportSyncSource).toContain(
"addEventListener('wheel', handleViewportWheel, { passive: false })",
);
expect(workloadsWorkloadViewportSyncSource).toContain(
"addEventListener('touchmove', handleViewportTouchMove, { passive: false })",
);
expect(workloadsWorkloadViewportSyncSource).toContain('bindWindowedPageScrollEvents');
expect(workloadsWorkloadViewportSyncSource).not.toContain("addEventListener('touch");
expect(workloadsWorkloadViewportSyncSource).toContain('wheelDeltaInPixels');
expect(workloadsWorkloadViewportSyncSource).toContain(
"scrollContainer.scrollTo({ top: 0, behavior: 'smooth' })",
@@ -1302,8 +1298,7 @@ describe('Workloads performance contract', () => {
expect(workloadsWorkloadDerivedStateSource).toContain('useWorkloadViewportSync');
expect(workloadsWorkloadDerivedStateSource).not.toContain('window.addEventListener');
expect(workloadsWorkloadDerivedStateSource).not.toContain('getBoundingClientRect');
expect(workloadsWorkloadViewportSyncSource).toContain('window.addEventListener');
expect(workloadsWorkloadViewportSyncSource).toContain('window.removeEventListener');
expect(workloadsWorkloadViewportSyncSource).toContain('bindWindowedPageScrollEvents');
expect(workloadsWorkloadViewportSyncSource).toContain('getBoundingClientRect');
expect(workloadsWorkloadViewportSyncSource).toContain('groupedWindowing.onScroll');
expect(workloadsWorkloadRouteStateSource).not.toContain("from './workloadTopology'");
@@ -69,12 +69,9 @@ describe('useWorkloadViewportSync', () => {
expect(addEventListenerSpy).toHaveBeenCalledWith('wheel', expect.any(Function), {
passive: false,
});
expect(addEventListenerSpy).toHaveBeenCalledWith('touchstart', expect.any(Function), {
passive: true,
});
expect(addEventListenerSpy).toHaveBeenCalledWith('touchmove', expect.any(Function), {
passive: false,
});
expect(addEventListenerSpy.mock.calls.some(([type]) => String(type).startsWith('touch'))).toBe(
false,
);
expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function));
window.dispatchEvent(new Event('scroll'));
@@ -86,8 +83,9 @@ describe('useWorkloadViewportSync', () => {
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
expect(removeEventListenerSpy).toHaveBeenCalledWith('wheel', expect.any(Function));
expect(removeEventListenerSpy).toHaveBeenCalledWith('touchstart', expect.any(Function));
expect(removeEventListenerSpy).toHaveBeenCalledWith('touchmove', expect.any(Function));
expect(
removeEventListenerSpy.mock.calls.some(([type]) => String(type).startsWith('touch')),
).toBe(false);
expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function));
});
@@ -254,20 +252,6 @@ describe('useWorkloadViewportSync', () => {
const callsBeforeExpandedInput = onScroll.mock.calls.length;
appScrollContainer.dispatchEvent(wheelEvent);
expect(onScroll).toHaveBeenCalledTimes(callsBeforeExpandedInput);
const touchStart = new Event('touchstart');
Object.defineProperty(touchStart, 'touches', {
value: { item: () => ({ clientX: 200, clientY: 240 }) },
});
appScrollContainer.dispatchEvent(touchStart);
const touchMove = new Event('touchmove');
Object.defineProperty(touchMove, 'touches', {
value: { item: () => ({ clientX: 196, clientY: 120 }) },
});
appScrollContainer.scrollTop = 320;
appScrollContainer.dispatchEvent(touchMove);
expect(onScroll).toHaveBeenCalledTimes(callsBeforeExpandedInput);
expect(touchMove.defaultPrevented).toBe(false);
});
it('exposes one app-shell back-to-top action after sustained workload scrolling', async () => {
@@ -1,42 +1,15 @@
import { createEffect, createSignal, onCleanup, type Accessor } from 'solid-js';
import { findInlineDetailElement } from '@/components/shared/contextualFocus';
import {
bindWindowedPageScrollEvents,
findWindowedPageScrollContainer,
wheelDeltaInPixels,
} from '@/components/shared/windowedPageScroll';
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;
interface TouchPosition {
x: number;
y: number;
}
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;
while (parent && parent !== document.body && parent !== document.documentElement) {
const styles = getComputedStyle(parent);
const hasVerticalScrollRange =
parent.scrollHeight - parent.clientHeight > MIN_VERTICAL_SCROLL_RANGE_PX;
const ownsVerticalScrollBeforeOverflow = styles.overflowY === 'scroll';
if (
SCROLLABLE_OVERFLOW_PATTERN.test(styles.overflowY) &&
(ownsVerticalScrollBeforeOverflow || hasVerticalScrollRange)
) {
return parent;
}
parent = parent.parentElement;
}
return null;
};
interface WorkloadsWorkloadViewportSyncOptions {
expandedDetailActive?: Accessor<boolean>;
@@ -81,7 +54,7 @@ export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOp
});
}
const rect = body.getBoundingClientRect();
const scrollContainer = findScrollContainer(body);
const scrollContainer = findWindowedPageScrollContainer(body);
if (scrollContainer) {
setIsScrollToTopVisible(scrollContainer.scrollTop > SCROLL_TO_TOP_VISIBILITY_THRESHOLD_PX);
if (!options.groupedWindowing.isWindowed()) return;
@@ -134,7 +107,6 @@ export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOp
const handleViewportScroll = () => {
syncGuestWindowToViewport();
};
let lastTouchPosition: TouchPosition | null = null;
const handleViewportWheel = (event: Event) => {
const wheelEvent = event as WheelEvent;
// Let native input move first while a variable-height drawer is open.
@@ -153,69 +125,27 @@ export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOp
: window.innerHeight;
syncGuestWindowToViewport(false, wheelDeltaInPixels(wheelEvent, viewportHeight));
};
const handleViewportTouchStart = (event: Event) => {
const touch = (event as TouchEvent).touches.item(0);
lastTouchPosition = touch ? { x: touch.clientX, y: touch.clientY } : null;
};
const handleViewportTouchMove = (event: Event) => {
const touch = (event as TouchEvent).touches.item(0);
if (!touch) {
lastTouchPosition = null;
return;
}
const nextPosition = { x: touch.clientX, y: touch.clientY };
const previousPosition = lastTouchPosition;
lastTouchPosition = nextPosition;
if (
!previousPosition ||
!options.groupedWindowing.isWindowed() ||
options.expandedDetailActive?.()
) {
return;
}
const deltaX = previousPosition.x - nextPosition.x;
const deltaY = previousPosition.y - nextPosition.y;
if (deltaY === 0 || Math.abs(deltaY) <= Math.abs(deltaX)) return;
syncGuestWindowToViewport(false, deltaY);
};
const handleViewportTouchEnd = (event: Event) => {
const touch = (event as TouchEvent).touches.item(0);
lastTouchPosition = touch ? { x: touch.clientX, y: touch.clientY } : null;
};
const handleViewportResize = () => {
syncGuestWindowToViewport(true);
};
handleViewportResize();
const scrollContainer = findScrollContainer(options.tableBodyRef()!);
const scrollContainer = findWindowedPageScrollContainer(options.tableBodyRef()!);
const scrollTarget = scrollContainer ?? window;
scrollTarget.addEventListener('scroll', handleViewportScroll, { passive: true });
// These two pre-scroll listeners intentionally remain non-passive. That
// makes the browser wait for the bounded row window to move before its
// compositor advances the viewport; neither handler cancels native input.
scrollTarget.addEventListener('wheel', handleViewportWheel, { passive: false });
scrollTarget.addEventListener('touchstart', handleViewportTouchStart, { passive: true });
scrollTarget.addEventListener('touchmove', handleViewportTouchMove, { passive: false });
scrollTarget.addEventListener('touchend', handleViewportTouchEnd, { passive: true });
scrollTarget.addEventListener('touchcancel', handleViewportTouchEnd, { passive: true });
window.addEventListener('resize', handleViewportResize);
onCleanup(() => {
scrollTarget.removeEventListener('scroll', handleViewportScroll);
scrollTarget.removeEventListener('wheel', handleViewportWheel);
scrollTarget.removeEventListener('touchstart', handleViewportTouchStart);
scrollTarget.removeEventListener('touchmove', handleViewportTouchMove);
scrollTarget.removeEventListener('touchend', handleViewportTouchEnd);
scrollTarget.removeEventListener('touchcancel', handleViewportTouchEnd);
window.removeEventListener('resize', handleViewportResize);
});
onCleanup(
bindWindowedPageScrollEvents({
scrollTarget,
onScroll: handleViewportScroll,
onWheel: handleViewportWheel,
onResize: handleViewportResize,
}),
);
});
const scrollToTop = () => {
if (typeof window === 'undefined') return;
const body = options.tableBodyRef();
const scrollContainer = body ? findScrollContainer(body) : null;
const scrollContainer = body ? findWindowedPageScrollContainer(body) : null;
if (scrollContainer) {
scrollContainer.scrollTo({ top: 0, behavior: 'smooth' });
return;
@@ -88,6 +88,7 @@ import contextualFocusSource from '@/components/shared/contextualFocus.ts?raw';
import summaryCardInteractionSource from '@/components/shared/summaryCardInteraction.ts?raw';
import summaryRowActionButtonSource from '@/components/shared/SummaryRowActionButton.tsx?raw';
import summaryInteractionA11ySource from '@/components/shared/summaryInteractionA11y.ts?raw';
import windowedPageScrollSource from '@/components/shared/windowedPageScroll.ts?raw';
import tableSource from '@/components/shared/Table.tsx?raw';
import tableSortPresentationSource from '@/components/shared/tableSortPresentation.ts?raw';
import tableCardHeaderSource from '@/components/shared/TableCardHeader.tsx?raw';
@@ -95,6 +96,10 @@ import summaryTableFocusSource from '@/components/shared/summaryTableFocus.ts?ra
import tableCardSource from '@/components/shared/TableCard.tsx?raw';
import groupedTableModeSegmentedControlSource from '@/components/shared/GroupedTableModeSegmentedControl.tsx?raw';
import groupedTableRowPresentationSource from '@/components/shared/groupedTableRowPresentation.ts?raw';
import unifiedResourceTableViewportSyncSource from '@/components/Infrastructure/useUnifiedResourceTableViewportSync.ts?raw';
import storagePoolsTableWindowingSource from '@/components/Storage/useStoragePoolsTableWindowing.ts?raw';
import workloadViewportSyncSource from '@/components/Workloads/useWorkloadViewportSync.ts?raw';
import platformWindowedItemsSource from '@/features/platformPage/usePlatformWindowedItems.ts?raw';
import animatedNumberSource from '@/components/shared/AnimatedNumber.tsx?raw';
import animatedNumberModelSource from '@/components/shared/animatedNumberModel.ts?raw';
import animatedNumberStateSource from '@/components/shared/useAnimatedNumberState.ts?raw';
@@ -1382,6 +1387,22 @@ describe('shared primitive guardrails', () => {
expect(frontendIndexCssSource).toContain('contain: paint');
expect(frontendIndexCssSource).toContain('overflow-y: hidden');
expect(frontendIndexCssSource).toContain('overscroll-behavior-x: contain');
expect(windowedPageScrollSource).toContain(
"addEventListener('scroll', options.onScroll, { passive: true })",
);
expect(windowedPageScrollSource).toContain(
"addEventListener('wheel', options.onWheel, { passive: false })",
);
expect(windowedPageScrollSource).not.toContain("addEventListener('touch");
for (const source of [
platformWindowedItemsSource,
unifiedResourceTableViewportSyncSource,
storagePoolsTableWindowingSource,
workloadViewportSyncSource,
]) {
expect(source).toContain('bindWindowedPageScrollEvents');
expect(source).not.toContain("addEventListener('touch");
}
expect(frontendIndexCssSource).toContain(
'.table-scroll-shell > .table-fixed.platform-table th.platform-table-name-column',
);
@@ -0,0 +1,65 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
bindWindowedPageScrollEvents,
findWindowedPageScrollContainer,
wheelDeltaInPixels,
} from './windowedPageScroll';
import windowedPageScrollSource from './windowedPageScroll.ts?raw';
afterEach(() => {
document.body.replaceChildren();
vi.restoreAllMocks();
});
describe('windowedPageScroll', () => {
it('binds native scroll, wheel prewarming, and resize without intercepting touch', () => {
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
const onScroll = vi.fn();
const onWheel = vi.fn();
const onResize = vi.fn();
const dispose = bindWindowedPageScrollEvents({
scrollTarget: window,
onScroll,
onWheel,
onResize,
});
expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', onScroll, { passive: true });
expect(addEventListenerSpy).toHaveBeenCalledWith('wheel', onWheel, { passive: false });
expect(addEventListenerSpy).toHaveBeenCalledWith('resize', onResize);
expect(windowedPageScrollSource).not.toContain("addEventListener('touch");
window.dispatchEvent(new Event('scroll'));
window.dispatchEvent(new WheelEvent('wheel', { deltaY: 24 }));
window.dispatchEvent(new Event('resize'));
expect(onScroll).toHaveBeenCalledTimes(1);
expect(onWheel).toHaveBeenCalledTimes(1);
expect(onResize).toHaveBeenCalledTimes(1);
dispose();
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', onScroll);
expect(removeEventListenerSpy).toHaveBeenCalledWith('wheel', onWheel);
expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', onResize);
});
it('normalizes pixel, line, and page wheel deltas once for every consumer', () => {
expect(wheelDeltaInPixels(new WheelEvent('wheel', { deltaY: 3 }), 800)).toBe(3);
expect(wheelDeltaInPixels(new WheelEvent('wheel', { deltaMode: 1, deltaY: 3 }), 800)).toBe(48);
expect(wheelDeltaInPixels(new WheelEvent('wheel', { deltaMode: 2, deltaY: 3 }), 800)).toBe(
2_400,
);
});
it('finds the single vertical page-scroller ancestor', () => {
const scrollContainer = document.createElement('div');
scrollContainer.style.overflowY = 'scroll';
const child = document.createElement('div');
scrollContainer.append(child);
document.body.append(scrollContainer);
expect(findWindowedPageScrollContainer(child)).toBe(scrollContainer);
});
});
@@ -0,0 +1,57 @@
const SCROLLABLE_OVERFLOW_PATTERN = /(?:auto|scroll|overlay)/;
const MIN_VERTICAL_SCROLL_RANGE_PX = 1;
const WHEEL_LINE_HEIGHT_PX = 16;
type WindowedPageScrollTarget = HTMLElement | Window;
type WindowedPageScrollListener = (event: Event) => void;
interface WindowedPageScrollEventsOptions {
scrollTarget: WindowedPageScrollTarget;
onScroll: WindowedPageScrollListener;
onWheel: WindowedPageScrollListener;
onResize: WindowedPageScrollListener;
}
export const findWindowedPageScrollContainer = (element: HTMLElement): HTMLElement | null => {
let parent = element.parentElement;
while (parent && parent !== document.body && parent !== document.documentElement) {
const styles = getComputedStyle(parent);
const hasVerticalScrollRange =
parent.scrollHeight - parent.clientHeight > MIN_VERTICAL_SCROLL_RANGE_PX;
if (
SCROLLABLE_OVERFLOW_PATTERN.test(styles.overflowY) &&
(styles.overflowY === 'scroll' || hasVerticalScrollRange)
) {
return parent;
}
parent = parent.parentElement;
}
return null;
};
export const wheelDeltaInPixels = (event: WheelEvent, viewportHeight: number): 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;
};
/**
* Canonical input lifecycle for virtualized content inside the native page scroller.
*
* Touch intentionally has no listener here. The compositor moves the page first,
* then the passive scroll event advances the keyed-row runway without allowing
* scroll anchoring to consume the operator's gesture.
*/
export const bindWindowedPageScrollEvents = (
options: WindowedPageScrollEventsOptions,
): (() => void) => {
options.scrollTarget.addEventListener('scroll', options.onScroll, { passive: true });
options.scrollTarget.addEventListener('wheel', options.onWheel, { passive: false });
window.addEventListener('resize', options.onResize);
return () => {
options.scrollTarget.removeEventListener('scroll', options.onScroll);
options.scrollTarget.removeEventListener('wheel', options.onWheel);
window.removeEventListener('resize', options.onResize);
};
};
@@ -15,8 +15,8 @@ export interface PlatformWindowedRowsProps<Row> {
* Canonical bounded renderer for ordinary platform table rows.
*
* The full filtered/sorted result remains in memory, while only a directional
* runway is mounted. Spacer rows preserve native page scrolling and wheel/touch
* listeners move the runway before the compositor advances the viewport.
* runway is mounted. Spacer rows preserve native page scrolling; wheel input
* prewarms the runway while touch input remains compositor-native.
*/
export function PlatformWindowedRows<Row>(props: PlatformWindowedRowsProps<Row>) {
const windowing = usePlatformWindowedItems({
@@ -3,10 +3,16 @@ import { afterEach, describe, expect, it } from 'vitest';
import { PlatformWindowedRows } from '../PlatformWindowedRows';
import { PlatformWindowedList } from '../PlatformWindowedList';
import platformWindowedItemsSource from '../usePlatformWindowedItems.ts?raw';
describe('PlatformWindowedRows', () => {
afterEach(cleanup);
it('keeps phone touch scrolling on the browser-native page path', () => {
expect(platformWindowedItemsSource).toContain('bindWindowedPageScrollEvents');
expect(platformWindowedItemsSource).not.toContain("addEventListener('touch");
});
it('mounts small tables in full without virtual spacer rows', () => {
const items = () => Array.from({ length: 20 }, (_, index) => index);
const { container } = render(() => (
@@ -1,35 +1,15 @@
import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from 'solid-js';
import { useTableWindowing } from '@/components/Infrastructure/useTableWindowing';
import {
bindWindowedPageScrollEvents,
findWindowedPageScrollContainer,
wheelDeltaInPixels,
} from '@/components/shared/windowedPageScroll';
const DEFAULT_ESTIMATED_ITEM_HEIGHT = 40;
const DESKTOP_WINDOW_SIZE = 140;
const PHONE_WINDOW_SIZE = 36;
const SCROLLABLE_OVERFLOW_PATTERN = /(?:auto|scroll|overlay)/;
const WHEEL_LINE_HEIGHT_PX = 16;
const findScrollContainer = (element: HTMLElement): HTMLElement | null => {
let parent = element.parentElement;
while (parent && parent !== document.body && parent !== document.documentElement) {
const styles = getComputedStyle(parent);
const hasVerticalScrollRange = parent.scrollHeight - parent.clientHeight > 1;
if (
SCROLLABLE_OVERFLOW_PATTERN.test(styles.overflowY) &&
(styles.overflowY === 'scroll' || hasVerticalScrollRange)
) {
return parent;
}
parent = parent.parentElement;
}
return null;
};
const wheelDeltaInPixels = (event: WheelEvent, viewportHeight: number): 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;
};
export interface PlatformWindowedItemsOptions<Item> {
items: Accessor<readonly Item[]>;
estimatedItemHeight?: number;
@@ -40,8 +20,9 @@ export interface PlatformWindowedItemsOptions<Item> {
/**
* Shared native-scroll window controller for platform tables and card lists.
*
* The wheel/touch listeners project the next scroll position before the browser
* paints it, keeping a directional runway mounted during rapid input.
* Wheel input projects the next scroll position before the browser paints it.
* Touch scrolling stays compositor-native and updates through the scroll event
* so changing the keyed runway cannot consume a phone swipe through anchoring.
*/
export function usePlatformWindowedItems<Item>(options: PlatformWindowedItemsOptions<Item>) {
const defaultWindowSize =
@@ -84,7 +65,7 @@ export function usePlatformWindowedItems<Item>(options: PlatformWindowedItemsOpt
}
const segmentRect = anchor.getBoundingClientRect();
const scrollContainer = findScrollContainer(anchor);
const scrollContainer = findWindowedPageScrollContainer(anchor);
if (scrollContainer) {
const containerRect = scrollContainer.getBoundingClientRect();
windowing.onScroll(
@@ -107,8 +88,7 @@ export function usePlatformWindowedItems<Item>(options: PlatformWindowedItemsOpt
const anchor = anchorRef();
if (!anchor || !windowing.isWindowed()) return;
let lastTouchY: number | null = null;
const scrollContainer = findScrollContainer(anchor);
const scrollContainer = findWindowedPageScrollContainer(anchor);
const scrollTarget: HTMLElement | Window = scrollContainer ?? window;
const viewportHeight = () =>
scrollContainer?.clientHeight || window.innerHeight || estimatedItemHeight();
@@ -118,44 +98,17 @@ export function usePlatformWindowedItems<Item>(options: PlatformWindowedItemsOpt
if (wheelEvent.deltaY === 0) return;
syncWindowToViewport(wheelDeltaInPixels(wheelEvent, viewportHeight()));
};
const handleTouchStart = (event: Event) => {
lastTouchY = (event as TouchEvent).touches.item(0)?.clientY ?? null;
};
const handleTouchMove = (event: Event) => {
const nextTouchY = (event as TouchEvent).touches.item(0)?.clientY ?? null;
if (nextTouchY == null || lastTouchY == null) {
lastTouchY = nextTouchY;
return;
}
const deltaY = lastTouchY - nextTouchY;
lastTouchY = nextTouchY;
if (deltaY !== 0) syncWindowToViewport(deltaY);
};
const handleTouchEnd = () => {
lastTouchY = null;
};
const handleResize = () => syncWindowToViewport(0, true);
handleResize();
scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
// Let the keyed runway move before the compositor advances native scroll.
// The handlers never cancel input; non-passive ordering only prevents a
// fast wheel or fling from exposing an as-yet-unmounted region.
scrollTarget.addEventListener('wheel', handleWheel, { passive: false });
scrollTarget.addEventListener('touchstart', handleTouchStart, { passive: true });
scrollTarget.addEventListener('touchmove', handleTouchMove, { passive: false });
scrollTarget.addEventListener('touchend', handleTouchEnd, { passive: true });
scrollTarget.addEventListener('touchcancel', handleTouchEnd, { passive: true });
window.addEventListener('resize', handleResize);
onCleanup(() => {
scrollTarget.removeEventListener('scroll', handleScroll);
scrollTarget.removeEventListener('wheel', handleWheel);
scrollTarget.removeEventListener('touchstart', handleTouchStart);
scrollTarget.removeEventListener('touchmove', handleTouchMove);
scrollTarget.removeEventListener('touchend', handleTouchEnd);
scrollTarget.removeEventListener('touchcancel', handleTouchEnd);
window.removeEventListener('resize', handleResize);
});
onCleanup(
bindWindowedPageScrollEvents({
scrollTarget,
onScroll: handleScroll,
onWheel: handleWheel,
onResize: handleResize,
}),
);
});
return {