mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-12 06:19:11 +00:00
Keep virtualized tables on native touch scrolling
This commit is contained in:
+28
-14
@@ -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
|
||||
|
||||
+2
-1
@@ -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');
|
||||
|
||||
+15
-61
@@ -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 {
|
||||
|
||||
+3
-8
@@ -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'");
|
||||
|
||||
+6
-22
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user