mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
fix(frontend): preserve app-shell scroll on inline drawer focus
This commit is contained in:
@@ -3712,21 +3712,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"work_claims": [
|
||||
{
|
||||
"id": "codex-lane-l8",
|
||||
"agent_id": "codex",
|
||||
"summary": "Fix canonical row-focus close behavior so collapsing an inline drawer never leaves behind accidental filter state.",
|
||||
"target_id": "v6-ga-promotion",
|
||||
"claimed_at": "2026-04-01T22:34:28Z",
|
||||
"heartbeat_at": "2026-04-01T22:51:28Z",
|
||||
"expires_at": "2026-04-02T00:51:28Z",
|
||||
"work_item": {
|
||||
"kind": "lane",
|
||||
"id": "L8"
|
||||
}
|
||||
}
|
||||
],
|
||||
"work_claims": [],
|
||||
"open_decisions": [],
|
||||
"source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md",
|
||||
"resolved_decisions": [
|
||||
|
||||
@@ -142,6 +142,12 @@ auth bootstrap, theme sync, and post-reconnect hosted refresh work through
|
||||
Solid `onMount(...)` inside the runtime owner instead of letting `App.tsx`,
|
||||
`AppLayout.tsx`, or module-evaluation side effects reach directly into browser
|
||||
APIs before the hosted shell has mounted.
|
||||
That same hosted browser shell boundary also owns same-path query-state
|
||||
transitions that refresh hosted/org context without changing the active page.
|
||||
`frontend-modern/src/App.tsx` must preserve the mounted `.app-scroll-shell`
|
||||
position across those remount-like transitions so inline row focus, hosted
|
||||
drawer state, and org-context route updates do not present as a full-page
|
||||
refresh.
|
||||
Persisted billing state is now also part of that canonical boundary: when a
|
||||
recognized Cloud/MSP plan version is loaded or saved, the stored `plan_version`
|
||||
must canonicalize and `limits.max_monitored_systems` must reconcile to the authoritative
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"contract_file": "docs/release-control/v6/internal/subsystems/frontend-primitives.md",
|
||||
"status_file": "docs/release-control/v6/internal/status.json",
|
||||
"registry_file": "docs/release-control/v6/internal/subsystems/registry.json",
|
||||
"dependency_subsystem_ids": ["agent-lifecycle", "storage-recovery"]
|
||||
"dependency_subsystem_ids": ["agent-lifecycle", "cloud-paid", "storage-recovery"]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -153,7 +153,7 @@ work extends shared components instead of creating new local variants.
|
||||
8. Keep summary chart interaction identity on one shared helper. Summary surfaces that expose row-hover, group-hover, chart-hover, or route-focus-driven chart emphasis must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` and pass that same resolved scope into card-state, sparkline, and density-map primitives, rather than letting cards read `hovered || focused` while charts listen to a different page-local ID source. Hovering one summary chart must promote that series into the shared active entity so sibling cards highlight the same object instead of keeping chart-local hover islands, and hovering or pinning a workload group header, infrastructure cluster header, or storage pool-group header must scope the matching summary cards through that same shared contract instead of forking a page-local summary filter path. Sibling cards should surface that synchronized hover as one compact header readout through the shared summary-card contract, while the chart under the pointer keeps the only floating tooltip. `frontend-modern/src/components/Recovery/RecoverySummary.tsx` is explicitly outside this interaction dialect: recovery posture cards may share summary framing, but they must not silently grow row/group/chart hover behavior without a separate governed product decision.
|
||||
9. Keep page summaries page-scoped when table rows enter contextual focus. Route-backed row selection may add a focused label and shared series emphasis, but infrastructure, workloads, and storage summary cards must continue to render the page-level series set instead of collapsing the summary down to the selected row or replacing the global trend view with row-local empty states.
|
||||
10. Keep contextual row focus on the shared summary primitive. Summary surfaces and same-route table drill-ins must reuse `frontend-modern/src/components/shared/contextualFocus.ts` for interactive-series filtering, focused-name lookup, active-series derivation, local scroll preservation, and deliberate inline-detail reveal instead of rebuilding page-local `Set` filters, focused-label scans, drawer-aware scroll math, or ad hoc scroll restoration in each surface.
|
||||
11. Keep summary-to-table coordination deliberate, explicit, and reversible. Shared summary hover may highlight the matching table row when it is already visible, but transient chart hover must not auto-filter tables, auto-scroll the page, or reshuffle table ordering. When a page/group/entity scope is active on workloads, infrastructure, or storage, the page shell must surface that state through the shared `frontend-modern/src/components/shared/SummaryScopeBar.tsx` plus `frontend-modern/src/components/shared/summaryScopePresentation.ts` contract instead of inventing page-local chips, breadcrumbs, or hidden route-only focus. That shared scope presenter must read like native page context, not a pill/badge widget: it should distinguish transient preview from pinned focus with quiet inline language, keep the current scope visible even when the sticky summary is collapsed or off-screen, and expose a clear reset path for pinned scope so touch-only operators do not depend on hover or “click the same row again” discovery. When the active row is off-screen, page owners must still route through `frontend-modern/src/components/shared/summaryTableFocus.ts` and surface a lightweight `Jump to row` affordance that reveals and scrolls only on explicit user action. Deliberate row focus may reveal inline detail automatically, but that reveal must be drawer-aware: preserve already-good positions, avoid hard centering, and scroll only enough to keep the row header plus the top of the inline detail visible.
|
||||
11. Keep summary-to-table coordination deliberate, explicit, and reversible. Shared summary hover may highlight the matching table row when it is already visible, but transient chart hover must not auto-filter tables, auto-scroll the page, or reshuffle table ordering. When a page/group/entity scope is active on workloads, infrastructure, or storage, the page shell must surface that state through the shared `frontend-modern/src/components/shared/SummaryScopeBar.tsx` plus `frontend-modern/src/components/shared/summaryScopePresentation.ts` contract instead of inventing page-local chips, breadcrumbs, or hidden route-only focus. That shared scope presenter must read like native page context, not a pill/badge widget: it should distinguish transient preview from pinned focus with quiet inline language, keep the current scope visible even when the sticky summary is collapsed or off-screen, and expose a clear reset path for pinned scope so touch-only operators do not depend on hover or “click the same row again” discovery. When the active row is off-screen, page owners must still route through `frontend-modern/src/components/shared/summaryTableFocus.ts` and surface a lightweight `Jump to row` affordance that reveals and scrolls only on explicit user action. Deliberate row focus may reveal inline detail automatically, but that reveal must be drawer-aware: infrastructure and workload row toggles that already have the row in view must hand the current `.app-scroll-shell` position through `frontend-modern/src/utils/appShellScrollRestoration.ts` so the remounted shell in `frontend-modern/src/App.tsx` can reopen the inline detail in place instead of looking like a page refresh, while storage and other non-local reveal paths may still scroll only enough to keep the row header plus the top of the inline detail visible.
|
||||
Shared summary-linked rows and group headers must also route their preview
|
||||
semantics through
|
||||
`frontend-modern/src/components/shared/summaryInteractionA11y.ts`.
|
||||
|
||||
@@ -251,8 +251,10 @@ primitive render the emphasis, instead of layering lane-local row-fill classes
|
||||
that diverge across pages or wash out inline metric bars.
|
||||
`frontend-modern/src/components/Dashboard/useDashboardSelectionState.ts` must
|
||||
write workload selection back into the workloads route through the shared
|
||||
same-path route-state scheduler so opening a focused workload preserves scroll
|
||||
and does not look like a full page reload, and the governed infrastructure and
|
||||
same-path route-state scheduler, but the actual shell-position handoff for
|
||||
query-only row focus must go through `frontend-modern/src/utils/appShellScrollRestoration.ts`
|
||||
plus the root `frontend-modern/src/App.tsx` shell so opening a focused
|
||||
workload does not look like a full page reload, and the governed infrastructure and
|
||||
workloads summary surfaces must keep the summary page-scoped while that focus
|
||||
reuses the shared highlight contract; density maps may retain page-level
|
||||
context, but they must now also surface focused-entity detail inside the same
|
||||
@@ -269,9 +271,13 @@ infrastructure, or workloads hot paths.
|
||||
That same hot-path ownership now also covers deliberate inline-detail reveal.
|
||||
When a focused workload or infrastructure row opens its inline detail, the hot
|
||||
path may preserve scroll across same-route state writes, but the actual reveal
|
||||
must still flow through the shared contextual-focus and summary-table helpers,
|
||||
mark the movement as deliberate so route-state restore does not replay over it,
|
||||
and scroll only enough to keep the row header plus the top of the detail
|
||||
must still flow through the shared contextual-focus and summary-table helpers.
|
||||
Direct row toggles that already have the row in view must capture the current
|
||||
app-shell scroll position before the focus write and let the remounted root
|
||||
shell restore that position, so the interaction stays anchored instead of
|
||||
looking like a full refresh; non-local reveal paths may still mark the
|
||||
movement as deliberate so route-state restore does not replay over it, and
|
||||
then scroll only enough to keep the row header plus the top of the detail
|
||||
visible instead of hard-centering every expansion.
|
||||
That same hot-path ownership now includes summary cache invalidation.
|
||||
Infrastructure and workload summary caches may hydrate charts for fast remounts,
|
||||
|
||||
@@ -333,9 +333,12 @@ expanding the owning group only for that deliberate reveal path.
|
||||
That same reveal contract now also owns inline-detail expansion. When a pool or
|
||||
disk row is deliberately focused and its inline detail opens on the storage
|
||||
page, the detail row must publish the same canonical summary series ID through
|
||||
`data-inline-detail-for` and let the shared contextual-focus helper reveal only
|
||||
enough of that drawer to show the row header plus the top of the detail,
|
||||
instead of reverting to storage-local centering or a second row/detail ID map.
|
||||
`data-inline-detail-for`, and the shared contextual-focus helper may still
|
||||
reveal only enough of that drawer to show the row header plus the top of the
|
||||
detail instead of reverting to storage-local centering or a second row/detail
|
||||
ID map. Storage should not fork that behavior into a fully in-place
|
||||
infrastructure-style shell handoff unless a separately governed product model
|
||||
changes the storage interaction contract.
|
||||
|
||||
The recovery backend is a real product boundary, not just a helper package:
|
||||
`internal/recovery/` owns per-tenant SQLite persistence, rollup derivation,
|
||||
|
||||
@@ -180,8 +180,11 @@ assembly branch.
|
||||
provider-local hover aliases when the selected series is highlighted.
|
||||
10. Keep infrastructure contextual focus route-backed and page-scoped. When an
|
||||
infrastructure row opens its detail drawer, the selection must stay on the
|
||||
same route through canonical resource query state, preserve scroll via the
|
||||
shared route-state scheduler, and keep `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx`
|
||||
same route through canonical resource query state, and the shared
|
||||
summary-table focus/runtime contract plus the root app-shell restore path
|
||||
must let direct row toggles open that drawer in place instead of looking
|
||||
like a page refresh or remount. The
|
||||
summary must keep `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx`
|
||||
rendering the full page-level series set while only the focused label and
|
||||
highlight state change.
|
||||
11. Keep infrastructure summary hover scope on canonical unified-resource ids
|
||||
@@ -309,11 +312,15 @@ highlighting the same canonical resource IDs used by the unified-resource
|
||||
table and route state.
|
||||
That same infrastructure focus boundary now also owns deliberate drawer reveal.
|
||||
When an infrastructure row opens inline detail on the same route, the page must
|
||||
tag that detail with the canonical active resource ID and let the shared
|
||||
tag that detail with the canonical active resource ID. Direct row toggles that
|
||||
already have the row in view must capture the current `.app-scroll-shell`
|
||||
position through `frontend-modern/src/utils/appShellScrollRestoration.ts`, so
|
||||
the remounted root shell in `frontend-modern/src/App.tsx` can stay anchored,
|
||||
while non-local reveal paths may still let the shared
|
||||
summary-table/contextual-focus helpers reveal only enough of the drawer to show
|
||||
the row header plus the start of the detail. `useUnifiedResourceTableViewportSync.ts`
|
||||
must stay viewport-only; it may not grow a second selected-row reveal path or a
|
||||
resource-local centering rule.
|
||||
the row header plus the start of the detail.
|
||||
`useUnifiedResourceTableViewportSync.ts` must stay viewport-only; it may not
|
||||
grow a second selected-row reveal path or a resource-local centering rule.
|
||||
That same unified-resource boundary now also owns stored metrics-target
|
||||
continuity for provider-backed resources. When registry rebuild cannot derive a
|
||||
fresh metrics target from raw source facets, `internal/unifiedresources/registry.go`
|
||||
|
||||
@@ -42,6 +42,10 @@ import {
|
||||
} from './routing/resourceLinks';
|
||||
import { AppLayout } from '@/AppLayout';
|
||||
import { useAppRuntimeState } from '@/useAppRuntimeState';
|
||||
import {
|
||||
clearPendingAppShellRestoreTop,
|
||||
readPendingAppShellRestoreTop,
|
||||
} from '@/utils/appShellScrollRestoration';
|
||||
|
||||
function isPublicRoutePath(pathname: string): boolean {
|
||||
// Public routes must be viewable without authentication.
|
||||
@@ -223,9 +227,84 @@ function App() {
|
||||
const RootLayout = (props: { children?: JSX.Element }) => {
|
||||
const [shortcutsOpen, setShortcutsOpen] = createSignal(false);
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = createSignal(false);
|
||||
const [appScrollShellRef, setAppScrollShellRef] = createSignal<HTMLDivElement | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [pendingAppShellRestoreTop, setPendingAppShellRestoreTop] = createSignal<number | null>(
|
||||
null,
|
||||
);
|
||||
const location = useLocation();
|
||||
const isPublicRoute = createMemo(() => isPublicRoutePath(location.pathname));
|
||||
|
||||
createEffect(() => {
|
||||
location.pathname;
|
||||
location.search;
|
||||
const pendingRestoreTop = readPendingAppShellRestoreTop();
|
||||
if (pendingRestoreTop !== null) {
|
||||
setPendingAppShellRestoreTop(pendingRestoreTop);
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const shell = appScrollShellRef();
|
||||
const restoreTop = pendingAppShellRestoreTop();
|
||||
if (!shell || restoreTop === null) {
|
||||
return;
|
||||
}
|
||||
if (Math.abs(shell.scrollTop - restoreTop) <= 2) {
|
||||
clearPendingAppShellRestoreTop();
|
||||
setPendingAppShellRestoreTop(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
let rafId: number | undefined;
|
||||
let timeoutId: number | undefined;
|
||||
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (rafId !== undefined) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
}
|
||||
if (timeoutId !== undefined) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
const attemptRestore = (remainingFrames: number) => {
|
||||
if (settled) return;
|
||||
const maxScrollTop = Math.max(0, shell.scrollHeight - shell.clientHeight);
|
||||
if (restoreTop <= maxScrollTop) {
|
||||
shell.scrollTop = restoreTop;
|
||||
if (Math.abs(shell.scrollTop - restoreTop) <= 2) {
|
||||
clearPendingAppShellRestoreTop();
|
||||
setPendingAppShellRestoreTop(null);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (remainingFrames <= 0) {
|
||||
clearPendingAppShellRestoreTop();
|
||||
setPendingAppShellRestoreTop(null);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
rafId = window.requestAnimationFrame(() => attemptRestore(remainingFrames - 1));
|
||||
};
|
||||
|
||||
rafId = window.requestAnimationFrame(() => attemptRestore(90));
|
||||
timeoutId = window.setTimeout(() => {
|
||||
const maxScrollTop = Math.max(0, shell.scrollHeight - shell.clientHeight);
|
||||
shell.scrollTop = Math.min(restoreTop, maxScrollTop);
|
||||
clearPendingAppShellRestoreTop();
|
||||
setPendingAppShellRestoreTop(null);
|
||||
finish();
|
||||
}, 1500);
|
||||
|
||||
onCleanup(finish);
|
||||
});
|
||||
|
||||
useKeyboardShortcuts({
|
||||
enabled: () => !runtime.needsAuth(),
|
||||
isShortcutsOpen: shortcutsOpen,
|
||||
@@ -322,6 +401,7 @@ function App() {
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
{/* Main content area - shrinks when AI panel is open, scrolls independently */}
|
||||
<div
|
||||
ref={setAppScrollShellRef}
|
||||
class={`app-scroll-shell flex-1 min-w-0 overflow-y-scroll bg-base text-base-content font-sans py-4 sm:py-6 transition-all duration-300`}
|
||||
>
|
||||
<AppLayout
|
||||
|
||||
@@ -8,7 +8,13 @@ describe('App architecture', () => {
|
||||
expect(appSource).toContain('DASHBOARD_PATH,');
|
||||
expect(appSource).toContain("import { AppLayout } from '@/AppLayout';");
|
||||
expect(appSource).toContain("import { useAppRuntimeState } from '@/useAppRuntimeState';");
|
||||
expect(appSource).toContain("import {");
|
||||
expect(appSource).toContain("} from '@/utils/appShellScrollRestoration';");
|
||||
expect(appSource).toContain('const runtime = useAppRuntimeState();');
|
||||
expect(appSource).toContain('pendingAppShellRestoreTop');
|
||||
expect(appSource).toContain('setAppScrollShellRef');
|
||||
expect(appSource).toContain('readPendingAppShellRestoreTop');
|
||||
expect(appSource).toContain('clearPendingAppShellRestoreTop');
|
||||
expect(appSource).toContain('const ROOT_DASHBOARD_PATH = DASHBOARD_PATH;');
|
||||
expect(appSource).toContain('<Route path={ROOT_DASHBOARD_PATH} component={DashboardPage} />');
|
||||
expect(appSource).toContain('<Route path="/" component={() => <Navigate href={ROOT_DASHBOARD_PATH} />} />');
|
||||
|
||||
+10
-6
@@ -242,7 +242,7 @@ describe('useDashboardSelectionState', () => {
|
||||
expect(row.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
|
||||
it('reveals mounted workload detail after selecting a row', () => {
|
||||
it('opens mounted workload detail in place when the row itself triggers focus', () => {
|
||||
locationSearch = '?type=app-container&platform=truenas&agent=truenas-main';
|
||||
const workloadId = 'app-container:truenas-main:nextcloud';
|
||||
const [filteredGuests] = createSignal<WorkloadGuest[]>([
|
||||
@@ -306,11 +306,15 @@ describe('useDashboardSelectionState', () => {
|
||||
result.setSelectedGuestId(workloadId);
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(scrollToSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
behavior: 'smooth',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
scrollToSpy.mock.calls.some(
|
||||
([firstArg]) =>
|
||||
Boolean(firstArg) &&
|
||||
typeof firstArg === 'object' &&
|
||||
'behavior' in firstArg &&
|
||||
firstArg.behavior === 'smooth',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('tracks hovered workload groups without letting them override entity selection outside scope', () => {
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { useLocation, useNavigate } from '@solidjs/router';
|
||||
import { createEffect, createMemo, createSignal, onCleanup, untrack, type Accessor } from 'solid-js';
|
||||
|
||||
import {
|
||||
findInlineDetailElement,
|
||||
preserveScrollableAncestorVerticalOffset,
|
||||
revealInlineDetailInViewport,
|
||||
} from '@/components/shared/contextualFocus';
|
||||
import { preserveScrollableAncestorVerticalOffset } from '@/components/shared/contextualFocus';
|
||||
import { useSummaryPageInteractionState } from '@/components/shared/summaryTableFocus';
|
||||
import {
|
||||
isSummarySeriesInGroupScope,
|
||||
type SummarySeriesGroupScope,
|
||||
} from '@/components/shared/summaryCardInteraction';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import { capturePendingAppShellRestoreTop } from '@/utils/appShellScrollRestoration';
|
||||
import { createRouteStateNavigateScheduler } from '@/utils/routeStateNavigation';
|
||||
import {
|
||||
dashboardHasHoveredWorkload,
|
||||
@@ -41,6 +38,7 @@ export function useDashboardSelectionState(options: UseDashboardSelectionStateOp
|
||||
const [handledResourceId, setHandledResourceId] = createSignal<string | null>(null);
|
||||
const [handledWorkloadGroupId, setHandledWorkloadGroupId] = createSignal<string | null>(null);
|
||||
const [revealedGuestId, setRevealedGuestId] = createSignal<string | null>(null);
|
||||
const [skipNextFocusedReveal, setSkipNextFocusedReveal] = createSignal(false);
|
||||
|
||||
const [tableWrapperRef, setTableWrapperRefSignal] = createSignal<HTMLDivElement | undefined>(
|
||||
undefined,
|
||||
@@ -60,6 +58,13 @@ export function useDashboardSelectionState(options: UseDashboardSelectionStateOp
|
||||
focusedSeriesId: selectedGuestId,
|
||||
focusedGroupScope: focusedWorkloadGroupScope,
|
||||
revealActiveSeries: setRevealedGuestId,
|
||||
consumeNextFocusedRevealSkip: () => {
|
||||
const shouldSkip = skipNextFocusedReveal();
|
||||
if (shouldSkip) {
|
||||
setSkipNextFocusedReveal(false);
|
||||
}
|
||||
return shouldSkip;
|
||||
},
|
||||
});
|
||||
|
||||
const setTableWrapperRef = (element: HTMLDivElement | undefined) => {
|
||||
@@ -74,6 +79,10 @@ export function useDashboardSelectionState(options: UseDashboardSelectionStateOp
|
||||
};
|
||||
|
||||
const setSelectedGuestId = (id: string | null) => {
|
||||
capturePendingAppShellRestoreTop();
|
||||
if (id) {
|
||||
setSkipNextFocusedReveal(true);
|
||||
}
|
||||
const activeFocusedGroupScope = focusedWorkloadGroupScope();
|
||||
const nextGroupScope =
|
||||
activeFocusedGroupScope && !isSummarySeriesInGroupScope(activeFocusedGroupScope, id)
|
||||
@@ -207,52 +216,6 @@ export function useDashboardSelectionState(options: UseDashboardSelectionStateOp
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const selectedId = selectedGuestId();
|
||||
const root = tableWrapperRef();
|
||||
if (!selectedId || !root || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
let rafId: number | undefined;
|
||||
let timeoutId: number | undefined;
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
settled = true;
|
||||
if (rafId !== undefined) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
}
|
||||
if (timeoutId !== undefined) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
const attemptReveal = (remainingFrames: number) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const row = root.querySelector<HTMLElement>(`[data-summary-series-id="${selectedId}"]`);
|
||||
const detail = findInlineDetailElement(root, selectedId);
|
||||
if (row && detail) {
|
||||
const didScroll = revealInlineDetailInViewport({ row, detail });
|
||||
if (!didScroll || remainingFrames <= 0) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
} else if (remainingFrames <= 0) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
rafId = window.requestAnimationFrame(() => attemptReveal(remainingFrames - 1));
|
||||
};
|
||||
|
||||
rafId = window.requestAnimationFrame(() => attemptReveal(24));
|
||||
timeoutId = window.setTimeout(cleanup, 1500);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
routeStateNavigate.cleanup();
|
||||
});
|
||||
|
||||
@@ -312,6 +312,7 @@ describe('shared primitive guardrails', () => {
|
||||
expect(summaryTableFocusSource).toContain('focusedSeriesId');
|
||||
expect(summaryTableFocusSource).toContain('findInlineDetailElement');
|
||||
expect(summaryTableFocusSource).toContain('revealInlineDetailInViewport');
|
||||
expect(summaryTableFocusSource).toContain('consumeNextFocusedRevealSkip');
|
||||
expect(summaryTableFocusSource).toContain('MutationObserver');
|
||||
expect(summaryTableFocusSource).toContain('querySelector<HTMLElement>(');
|
||||
expect(summaryTableFocusSource).toContain(
|
||||
|
||||
@@ -152,4 +152,46 @@ describe('useSummaryPageInteractionState', () => {
|
||||
expect(revealActiveSeries).toHaveBeenCalledWith('workload-a');
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 456, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
it('skips the next focused reveal when the interaction owner suppresses inline auto-scroll', () => {
|
||||
const [focusedSeriesId] = createSignal<string | null>('resource-a');
|
||||
const [skipNextFocusedReveal, setSkipNextFocusedReveal] = createSignal(true);
|
||||
const revealActiveSeries = vi.fn();
|
||||
const scrollTo = vi.fn();
|
||||
const root = document.createElement('div');
|
||||
const row = document.createElement('div');
|
||||
const detail = document.createElement('div');
|
||||
|
||||
Object.defineProperty(window, 'scrollTo', {
|
||||
configurable: true,
|
||||
value: scrollTo,
|
||||
});
|
||||
|
||||
row.setAttribute('data-summary-series-id', 'resource-a');
|
||||
row.getBoundingClientRect = vi.fn(() => buildRect(680, 40));
|
||||
detail.setAttribute('data-inline-detail-for', 'resource-a');
|
||||
detail.getBoundingClientRect = vi.fn(() => buildRect(724, 220));
|
||||
root.append(row, detail);
|
||||
document.body.appendChild(root);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSummaryPageInteractionState({
|
||||
focusedSeriesId,
|
||||
revealActiveSeries,
|
||||
consumeNextFocusedRevealSkip: () => {
|
||||
const shouldSkip = skipNextFocusedReveal();
|
||||
if (shouldSkip) {
|
||||
setSkipNextFocusedReveal(false);
|
||||
}
|
||||
return shouldSkip;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
result.setTableRootRef(root);
|
||||
|
||||
expect(revealActiveSeries).not.toHaveBeenCalled();
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
expect(skipNextFocusedReveal()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from 'solid-js';
|
||||
import { createEffect, createMemo, createSignal, onCleanup, untrack, type Accessor } from 'solid-js';
|
||||
import {
|
||||
findInlineDetailElement,
|
||||
revealInlineDetailInViewport,
|
||||
@@ -52,6 +52,7 @@ export interface UseSummaryTableFocusBridgeOptions {
|
||||
activeSeriesId: Accessor<string | null | undefined>;
|
||||
focusedSeriesId?: Accessor<string | null | undefined>;
|
||||
revealActiveSeries?: (seriesId: string) => void;
|
||||
consumeNextFocusedRevealSkip?: () => boolean;
|
||||
}
|
||||
|
||||
export function useSummaryTableFocusBridge(options: UseSummaryTableFocusBridgeOptions) {
|
||||
@@ -127,6 +128,10 @@ export function useSummaryTableFocusBridge(options: UseSummaryTableFocusBridgeOp
|
||||
return;
|
||||
}
|
||||
|
||||
if (untrack(() => options.consumeNextFocusedRevealSkip?.() ?? false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
options.revealActiveSeries?.(focusedId);
|
||||
|
||||
let settled = false;
|
||||
@@ -230,6 +235,7 @@ export interface UseSummaryPageInteractionStateOptions {
|
||||
hoveredGroupScope?: Accessor<SummarySeriesGroupScope | null | undefined>;
|
||||
focusedGroupScope?: Accessor<SummarySeriesGroupScope | null | undefined>;
|
||||
revealActiveSeries?: (seriesId: string) => void;
|
||||
consumeNextFocusedRevealSkip?: () => boolean;
|
||||
}
|
||||
|
||||
export function useSummaryPageInteractionState(options: UseSummaryPageInteractionStateOptions) {
|
||||
@@ -270,6 +276,7 @@ export function useSummaryPageInteractionState(options: UseSummaryPageInteractio
|
||||
activeSeriesId,
|
||||
focusedSeriesId,
|
||||
revealActiveSeries: options.revealActiveSeries,
|
||||
consumeNextFocusedRevealSkip: options.consumeNextFocusedRevealSkip,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
+6
@@ -64,11 +64,17 @@ describe('InfrastructurePageSurface guardrails', () => {
|
||||
expect(infrastructurePageStateSource).toContain('jumpToActiveResourceRow');
|
||||
expect(infrastructurePageStateSource).toContain('setSummaryTableRootRef');
|
||||
expect(infrastructurePageStateSource).toContain('shouldShowJumpToActiveResourceRow');
|
||||
expect(infrastructurePageStateSource).toContain('preserveScrollableAncestorVerticalOffset');
|
||||
expect(infrastructurePageStateSource).toContain('consumeNextFocusedRevealSkip');
|
||||
expect(infrastructurePageStateSource).toContain('setSkipNextFocusedReveal(true)');
|
||||
expect(infrastructurePageStateSource).not.toContain('querySelector<HTMLElement>(');
|
||||
expect(infrastructurePageStateSource).not.toContain('scrollIntoView({ behavior: \'smooth\', block: \'center\' })');
|
||||
|
||||
expect(infrastructurePageRouteStateSource).not.toContain('useSummaryPageInteractionState');
|
||||
expect(infrastructurePageRouteStateSource).not.toContain('setSummaryTableRootRef');
|
||||
expect(infrastructurePageRouteStateSource).toContain('createRouteStateNavigateScheduler');
|
||||
expect(infrastructurePageRouteStateSource).not.toContain('replaceSamePathRouteState');
|
||||
expect(infrastructurePageRouteStateSource).not.toContain('createRouteStateSearchSignal');
|
||||
});
|
||||
|
||||
it('keeps inline-detail reveal out of infrastructure viewport sync helpers', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createEffect, createMemo, createSignal } from 'solid-js';
|
||||
import type { TimeRange } from '@/api/charts';
|
||||
import { preserveScrollableAncestorVerticalOffset } from '@/components/shared/contextualFocus';
|
||||
import { useUnifiedResources } from '@/hooks/useUnifiedResources';
|
||||
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
|
||||
import { useBreakpoint } from '@/hooks/useBreakpoint';
|
||||
@@ -8,6 +9,7 @@ import { useKioskMode } from '@/hooks/useKioskMode';
|
||||
import { useSummaryPageInteractionState } from '@/components/shared/summaryTableFocus';
|
||||
import { isSummaryTimeRange } from '@/components/shared/summaryTimeRange';
|
||||
import { buildSummaryScopePresentation } from '@/components/shared/summaryScopePresentation';
|
||||
import { capturePendingAppShellRestoreTop } from '@/utils/appShellScrollRestoration';
|
||||
import {
|
||||
buildInfrastructureSummaryGroupScope,
|
||||
groupResources,
|
||||
@@ -56,6 +58,8 @@ export function useInfrastructurePageState() {
|
||||
createSignal<SummarySeriesGroupScope | null>(null);
|
||||
const [deployCluster, setDeployCluster] = createSignal<DeployCluster | null>(null);
|
||||
const [filtersOpen, setFiltersOpen] = createSignal(false);
|
||||
const [tableRootRef, setTableRootRef] = createSignal<HTMLDivElement | undefined>(undefined);
|
||||
const [skipNextFocusedReveal, setSkipNextFocusedReveal] = createSignal(false);
|
||||
|
||||
const hasResources = createMemo(() => resources().length > 0);
|
||||
const showNoResources = createMemo(() => initialLoadComplete() && !hasResources() && !error());
|
||||
@@ -106,18 +110,52 @@ export function useInfrastructurePageState() {
|
||||
focusedSeriesId: routeState.expandedResourceId,
|
||||
focusedGroupScope: focusedResourceGroupScope,
|
||||
revealActiveSeries: routeState.setRevealedResourceId,
|
||||
consumeNextFocusedRevealSkip: () => {
|
||||
const shouldSkip = skipNextFocusedReveal();
|
||||
if (shouldSkip) {
|
||||
setSkipNextFocusedReveal(false);
|
||||
}
|
||||
return shouldSkip;
|
||||
},
|
||||
});
|
||||
const setSummaryTableRootRef = (element: HTMLDivElement | undefined) => {
|
||||
setTableRootRef(element);
|
||||
summaryInteraction.setTableRootRef(element);
|
||||
};
|
||||
|
||||
const preserveTableScrollAnchor = (apply: () => void) => {
|
||||
preserveScrollableAncestorVerticalOffset(tableRootRef(), apply);
|
||||
};
|
||||
|
||||
const setExpandedResourceId = (resourceId: string | null) => {
|
||||
capturePendingAppShellRestoreTop();
|
||||
if (resourceId) {
|
||||
setSkipNextFocusedReveal(true);
|
||||
}
|
||||
const groupScope = focusedResourceGroupScope();
|
||||
if (groupScope && resourceId && !isSummarySeriesInGroupScope(groupScope, resourceId)) {
|
||||
routeState.setFocusedResourceGroupId(null);
|
||||
preserveTableScrollAnchor(() => {
|
||||
routeState.setFocusedResourceGroupId(null);
|
||||
});
|
||||
}
|
||||
routeState.setExpandedResourceId(resourceId);
|
||||
preserveTableScrollAnchor(() => {
|
||||
routeState.setExpandedResourceId(resourceId);
|
||||
});
|
||||
};
|
||||
|
||||
const setFocusedResourceGroupId = (groupId: string | null) => {
|
||||
capturePendingAppShellRestoreTop();
|
||||
preserveTableScrollAnchor(() => {
|
||||
routeState.setFocusedResourceGroupId(groupId);
|
||||
});
|
||||
};
|
||||
|
||||
const clearPinnedSummaryScope = () => {
|
||||
routeState.setExpandedResourceId(null);
|
||||
routeState.setFocusedResourceGroupId(null);
|
||||
capturePendingAppShellRestoreTop();
|
||||
preserveTableScrollAnchor(() => {
|
||||
routeState.setExpandedResourceId(null);
|
||||
routeState.setFocusedResourceGroupId(null);
|
||||
});
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
@@ -256,8 +294,9 @@ export function useInfrastructurePageState() {
|
||||
setExpandedResourceId,
|
||||
setChartHoverSync: summaryInteraction.setChartHoverSync,
|
||||
setHoveredResourceGroupScope,
|
||||
setSummaryTableRootRef: summaryInteraction.setTableRootRef,
|
||||
setSummaryTableRootRef,
|
||||
summaryScopePresentation,
|
||||
shouldShowJumpToActiveResourceRow: summaryInteraction.shouldShowJumpToActiveRow,
|
||||
setFocusedResourceGroupId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
createRouteStateNavigateScheduler,
|
||||
markRouteStateDeliberateScroll,
|
||||
} from '@/utils/routeStateNavigation';
|
||||
import {
|
||||
clearPendingAppShellRestoreTop,
|
||||
readPendingAppShellRestoreTop,
|
||||
} from '@/utils/appShellScrollRestoration';
|
||||
|
||||
describe('routeStateNavigation', () => {
|
||||
const scrollToSpy = vi.fn();
|
||||
@@ -13,6 +17,7 @@ describe('routeStateNavigation', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
clearPendingAppShellRestoreTop();
|
||||
scrollToSpy.mockReset();
|
||||
currentScrollX = 24;
|
||||
currentScrollY = 320;
|
||||
@@ -59,6 +64,25 @@ describe('routeStateNavigation', () => {
|
||||
expect(scrollToSpy).toHaveBeenCalledWith(24, 320);
|
||||
});
|
||||
|
||||
it('captures the app scroll shell before same-path route state navigation', () => {
|
||||
const navigate = vi.fn();
|
||||
let currentPath = '/infrastructure?source=proxmox-pve';
|
||||
const scheduler = createRouteStateNavigateScheduler(navigate, () => currentPath);
|
||||
const shell = document.createElement('div');
|
||||
shell.className = 'app-scroll-shell';
|
||||
shell.scrollTop = 55;
|
||||
document.body.appendChild(shell);
|
||||
|
||||
scheduler.schedule('/infrastructure?source=proxmox-pve&resource=agent-123');
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith(
|
||||
'/infrastructure?source=proxmox-pve&resource=agent-123',
|
||||
ROUTE_STATE_REPLACE_OPTIONS,
|
||||
);
|
||||
expect(readPendingAppShellRestoreTop()).toBe(55);
|
||||
});
|
||||
|
||||
it('skips redundant navigations to the current path', () => {
|
||||
const navigate = vi.fn();
|
||||
const scheduler = createRouteStateNavigateScheduler(navigate, () => '/recovery?rollupId=abc');
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
let pendingAppShellRestoreTop: number | null = null;
|
||||
const APP_SCROLL_SHELL_SELECTOR = '.app-scroll-shell';
|
||||
|
||||
export const schedulePendingAppShellRestoreTop = (scrollTop: number): void => {
|
||||
pendingAppShellRestoreTop = Math.max(0, scrollTop);
|
||||
};
|
||||
|
||||
export const capturePendingAppShellRestoreTop = (): void => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const shell = document.querySelector<HTMLElement>(APP_SCROLL_SHELL_SELECTOR);
|
||||
if (!shell || shell.scrollTop <= 0) {
|
||||
return;
|
||||
}
|
||||
schedulePendingAppShellRestoreTop(shell.scrollTop);
|
||||
};
|
||||
|
||||
export const readPendingAppShellRestoreTop = (): number | null => pendingAppShellRestoreTop;
|
||||
|
||||
export const clearPendingAppShellRestoreTop = (): void => {
|
||||
pendingAppShellRestoreTop = null;
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import { schedulePendingAppShellRestoreTop } from '@/utils/appShellScrollRestoration';
|
||||
|
||||
export const ROUTE_STATE_REPLACE_OPTIONS = {
|
||||
replace: true,
|
||||
scroll: false,
|
||||
@@ -19,6 +21,13 @@ const isSamePathnameNavigation = (currentPath: string, targetPath: string): bool
|
||||
return new URL(currentPath, base).pathname === new URL(targetPath, base).pathname;
|
||||
};
|
||||
|
||||
const shouldRestorePreservedScrollValue = (current: number, restore: number): boolean => {
|
||||
if (Math.abs(current - restore) <= ROUTE_STATE_SCROLL_RESTORE_DIVERGENCE_PX) {
|
||||
return true;
|
||||
}
|
||||
return restore > ROUTE_STATE_SCROLL_RESTORE_DIVERGENCE_PX && current === 0;
|
||||
};
|
||||
|
||||
export const createRouteStateNavigateScheduler = (
|
||||
navigate: (path: string, options: typeof ROUTE_STATE_REPLACE_OPTIONS) => void,
|
||||
readCurrentPath: () => string,
|
||||
@@ -45,14 +54,18 @@ export const createRouteStateNavigateScheduler = (
|
||||
if (restoreScroll) {
|
||||
const previousScrollRestoration = window.history.scrollRestoration;
|
||||
window.history.scrollRestoration = 'manual';
|
||||
const shell = document.querySelector<HTMLElement>('.app-scroll-shell');
|
||||
if (shell && shell.scrollTop > 0) {
|
||||
schedulePendingAppShellRestoreTop(shell.scrollTop);
|
||||
}
|
||||
const applyScrollRestore = () => {
|
||||
if (/jsdom/i.test(window.navigator.userAgent)) return;
|
||||
if (routeStateDeliberateScrollSuppressedUntil > Date.now()) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
Math.abs(window.scrollX - restoreScroll.x) > ROUTE_STATE_SCROLL_RESTORE_DIVERGENCE_PX ||
|
||||
Math.abs(window.scrollY - restoreScroll.y) > ROUTE_STATE_SCROLL_RESTORE_DIVERGENCE_PX
|
||||
!shouldRestorePreservedScrollValue(window.scrollX, restoreScroll.x) ||
|
||||
!shouldRestorePreservedScrollValue(window.scrollY, restoreScroll.y)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,15 +89,45 @@ async function scrollSectionIntoView(
|
||||
): Promise<number> {
|
||||
const top = await locator.evaluate(
|
||||
(element, sectionOffset) =>
|
||||
Math.max(
|
||||
0,
|
||||
window.scrollY + element.getBoundingClientRect().top - sectionOffset,
|
||||
),
|
||||
(() => {
|
||||
const shell = document.querySelector<HTMLElement>(".app-scroll-shell");
|
||||
if (shell && shell.contains(element)) {
|
||||
const shellRect = shell.getBoundingClientRect();
|
||||
return Math.max(
|
||||
0,
|
||||
shell.scrollTop +
|
||||
element.getBoundingClientRect().top -
|
||||
shellRect.top -
|
||||
sectionOffset,
|
||||
);
|
||||
}
|
||||
return Math.max(
|
||||
0,
|
||||
window.scrollY + element.getBoundingClientRect().top - sectionOffset,
|
||||
);
|
||||
})(),
|
||||
offset,
|
||||
);
|
||||
await page.evaluate((nextTop) => window.scrollTo(0, nextTop), top);
|
||||
await page.evaluate((nextTop) => {
|
||||
const shell = document.querySelector<HTMLElement>(".app-scroll-shell");
|
||||
if (shell) {
|
||||
shell.scrollTop = nextTop;
|
||||
return;
|
||||
}
|
||||
window.scrollTo(0, nextTop);
|
||||
}, top);
|
||||
await page.waitForTimeout(150);
|
||||
return page.evaluate(() => window.scrollY);
|
||||
return page.evaluate(() => {
|
||||
const shell = document.querySelector<HTMLElement>(".app-scroll-shell");
|
||||
return shell ? shell.scrollTop : window.scrollY;
|
||||
});
|
||||
}
|
||||
|
||||
async function readPrimaryViewportScrollTop(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
const shell = document.querySelector<HTMLElement>(".app-scroll-shell");
|
||||
return shell ? shell.scrollTop : window.scrollY;
|
||||
});
|
||||
}
|
||||
|
||||
async function positionElementNearViewportBottom(
|
||||
@@ -107,17 +137,60 @@ async function positionElementNearViewportBottom(
|
||||
): Promise<number> {
|
||||
const targetTop = await locator.evaluate(
|
||||
(element, inset) =>
|
||||
Math.max(
|
||||
0,
|
||||
window.scrollY + element.getBoundingClientRect().top - (window.innerHeight - inset),
|
||||
),
|
||||
(() => {
|
||||
const shell = document.querySelector<HTMLElement>(".app-scroll-shell");
|
||||
if (shell && shell.contains(element)) {
|
||||
const shellRect = shell.getBoundingClientRect();
|
||||
return Math.max(
|
||||
0,
|
||||
shell.scrollTop +
|
||||
element.getBoundingClientRect().top -
|
||||
shellRect.top -
|
||||
(shell.clientHeight - inset),
|
||||
);
|
||||
}
|
||||
return Math.max(
|
||||
0,
|
||||
window.scrollY +
|
||||
element.getBoundingClientRect().top -
|
||||
(window.innerHeight - inset),
|
||||
);
|
||||
})(),
|
||||
bottomInset,
|
||||
);
|
||||
await page.evaluate((nextTop) => window.scrollTo(0, nextTop), targetTop);
|
||||
await page.evaluate((nextTop) => {
|
||||
const shell = document.querySelector<HTMLElement>(".app-scroll-shell");
|
||||
if (shell) {
|
||||
shell.scrollTop = nextTop;
|
||||
return;
|
||||
}
|
||||
window.scrollTo(0, nextTop);
|
||||
}, targetTop);
|
||||
await page.waitForTimeout(150);
|
||||
return locator.evaluate((element) => element.getBoundingClientRect().top);
|
||||
}
|
||||
|
||||
async function readPrimaryViewportBounds(
|
||||
page: Page,
|
||||
): Promise<{ top: number; bottom: number; height: number }> {
|
||||
return page.evaluate(() => {
|
||||
const shell = document.querySelector<HTMLElement>(".app-scroll-shell");
|
||||
if (shell) {
|
||||
const rect = shell.getBoundingClientRect();
|
||||
return {
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
height: shell.clientHeight,
|
||||
};
|
||||
}
|
||||
return {
|
||||
top: 0,
|
||||
bottom: window.innerHeight,
|
||||
height: window.innerHeight,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function findLegacyScopedWorkloadRow(page: Page): Promise<Locator> {
|
||||
const rows = page.locator("tr[data-guest-id]");
|
||||
const rowCount = await rows.count();
|
||||
@@ -184,6 +257,7 @@ test.describe.serial("Inline selection scroll stability", () => {
|
||||
.first();
|
||||
await expect(row).toBeVisible();
|
||||
const beforeRowTop = await positionElementNearViewportBottom(page, row);
|
||||
const beforeClickScroll = await readPrimaryViewportScrollTop(page);
|
||||
expect(beforeRowTop).toBeGreaterThan(500);
|
||||
await row.click();
|
||||
|
||||
@@ -197,16 +271,18 @@ test.describe.serial("Inline selection scroll stability", () => {
|
||||
await expect(detailRow).toBeVisible();
|
||||
await page.waitForTimeout(350);
|
||||
|
||||
const viewportHeight = await page.evaluate(() => window.innerHeight);
|
||||
const viewportBounds = await readPrimaryViewportBounds(page);
|
||||
const afterRowTop = await row.evaluate((element) => element.getBoundingClientRect().top);
|
||||
const detailTop = await detailRow.evaluate((element) => element.getBoundingClientRect().top);
|
||||
|
||||
expect(afterRowTop).toBeLessThan(beforeRowTop - 150);
|
||||
expect(afterRowTop).toBeLessThan(viewportHeight * 0.5);
|
||||
expect(detailTop).toBeLessThan(viewportHeight - 48);
|
||||
expect(Math.abs(afterRowTop - beforeRowTop)).toBeLessThanOrEqual(12);
|
||||
expect(detailTop).toBeLessThan(viewportBounds.bottom - 48);
|
||||
|
||||
const afterScroll = await page.evaluate(() => window.scrollY);
|
||||
expect(afterScroll).toBeGreaterThanOrEqual(Math.max(10, beforeScroll - 60));
|
||||
const afterScroll = await page.evaluate(() => {
|
||||
const shell = document.querySelector<HTMLElement>(".app-scroll-shell");
|
||||
return shell ? shell.scrollTop : window.scrollY;
|
||||
});
|
||||
expect(Math.abs(afterScroll - beforeClickScroll)).toBeLessThanOrEqual(12);
|
||||
});
|
||||
|
||||
test("keeps the recovery viewport stable when selecting a protected item", async ({
|
||||
@@ -240,7 +316,7 @@ test.describe.serial("Inline selection scroll stability", () => {
|
||||
page.getByTestId("recovery-history-item-filter-trigger"),
|
||||
).not.toContainText("Any Item");
|
||||
|
||||
const afterScroll = await page.evaluate(() => window.scrollY);
|
||||
const afterScroll = await readPrimaryViewportScrollTop(page);
|
||||
expect(afterScroll).toBeGreaterThanOrEqual(Math.max(10, beforeScroll - 60));
|
||||
});
|
||||
|
||||
@@ -261,6 +337,7 @@ test.describe.serial("Inline selection scroll stability", () => {
|
||||
const beforeScroll = await scrollSectionIntoView(page, row);
|
||||
expect(beforeScroll).toBeGreaterThan(10);
|
||||
const beforeRowTop = await positionElementNearViewportBottom(page, row);
|
||||
const beforeClickScroll = await readPrimaryViewportScrollTop(page);
|
||||
expect(beforeRowTop).toBeGreaterThan(500);
|
||||
|
||||
const workloadId = (await row.getAttribute("data-guest-id")) ?? "";
|
||||
@@ -280,16 +357,18 @@ test.describe.serial("Inline selection scroll stability", () => {
|
||||
);
|
||||
await page.waitForTimeout(350);
|
||||
|
||||
const viewportHeight = await page.evaluate(() => window.innerHeight);
|
||||
const viewportBounds = await readPrimaryViewportBounds(page);
|
||||
const afterRowTop = await row.evaluate((element) => element.getBoundingClientRect().top);
|
||||
const detailTop = await detailRow.evaluate((element) => element.getBoundingClientRect().top);
|
||||
|
||||
expect(afterRowTop).toBeLessThan(beforeRowTop - 150);
|
||||
expect(afterRowTop).toBeLessThan(viewportHeight * 0.5);
|
||||
expect(detailTop).toBeLessThan(viewportHeight - 48);
|
||||
expect(Math.abs(afterRowTop - beforeRowTop)).toBeLessThanOrEqual(12);
|
||||
expect(detailTop).toBeLessThan(viewportBounds.bottom - 48);
|
||||
|
||||
const afterScroll = await page.evaluate(() => window.scrollY);
|
||||
expect(afterScroll).toBeGreaterThanOrEqual(Math.max(10, beforeScroll - 60));
|
||||
const afterScroll = await page.evaluate(() => {
|
||||
const shell = document.querySelector<HTMLElement>(".app-scroll-shell");
|
||||
return shell ? shell.scrollTop : window.scrollY;
|
||||
});
|
||||
expect(Math.abs(afterScroll - beforeClickScroll)).toBeLessThanOrEqual(12);
|
||||
|
||||
await row.click();
|
||||
await expect.poll(() => page.url()).not.toContain("resource=");
|
||||
@@ -359,6 +438,7 @@ test.describe.serial("Inline selection scroll stability", () => {
|
||||
const beforeScroll = await scrollSectionIntoView(page, row);
|
||||
expect(beforeScroll).toBeGreaterThan(10);
|
||||
const beforeRowTop = await positionElementNearViewportBottom(page, row);
|
||||
const beforeClickScroll = await readPrimaryViewportScrollTop(page);
|
||||
expect(beforeRowTop).toBeGreaterThan(500);
|
||||
|
||||
await row.click();
|
||||
@@ -367,12 +447,12 @@ test.describe.serial("Inline selection scroll stability", () => {
|
||||
await expect(detailRow).toBeVisible();
|
||||
await page.waitForTimeout(350);
|
||||
|
||||
const viewportHeight = await page.evaluate(() => window.innerHeight);
|
||||
const viewportBounds = await readPrimaryViewportBounds(page);
|
||||
const afterRowTop = await row.evaluate((element) => element.getBoundingClientRect().top);
|
||||
const detailTop = await detailRow.evaluate((element) => element.getBoundingClientRect().top);
|
||||
|
||||
expect(afterRowTop).toBeLessThan(beforeRowTop - 150);
|
||||
expect(afterRowTop).toBeLessThan(viewportHeight * 0.42);
|
||||
expect(detailTop).toBeLessThan(viewportHeight - 48);
|
||||
expect(afterRowTop).toBeLessThan(viewportBounds.top + viewportBounds.height * 0.42);
|
||||
expect(detailTop).toBeLessThan(viewportBounds.bottom - 48);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user