From 3300ece8ddb3bea490aa350301b60f047aa463b2 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 3 Apr 2026 00:36:06 +0100 Subject: [PATCH] fix(frontend): clear pinned scope from neutral interaction surfaces --- .../subsystems/frontend-primitives.md | 2 +- .../subsystems/performance-and-scalability.md | 2 +- .../internal/subsystems/storage-recovery.md | 7 +- .../internal/subsystems/unified-resources.md | 8 +- .../src/components/Dashboard/Dashboard.tsx | 168 +++++++++--------- .../Dashboard.performance.contract.test.tsx | 3 + .../Dashboard/useDashboardSelectionState.ts | 5 + .../components/Dashboard/useDashboardState.ts | 2 + .../src/components/Storage/Storage.tsx | 115 ++++++------ .../Storage/__tests__/Storage.test.tsx | 2 +- .../components/Storage/useStoragePageModel.ts | 1 + .../SharedPrimitives.guardrails.test.ts | 2 + .../__tests__/summaryTableFocus.test.tsx | 37 +++- .../components/shared/summaryTableFocus.ts | 13 +- .../InfrastructurePageSurface.tsx | 9 + ...frastructurePageSurface.guardrails.test.ts | 4 + .../useInfrastructurePageState.ts | 5 + .../tests/48-summary-hover-selection.spec.ts | 83 ++++++++- 18 files changed, 310 insertions(+), 158 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index d42440143..e00c6feb4 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -152,7 +152,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. Pinned page/group/entity scope on workloads, infrastructure, or storage must stay row-first: the pinned row or group header is the visible scoped state, not a second strip or search-row widget. Page shells therefore must not reintroduce always-on scope banners, preview bars, page-local chips, breadcrumbs, or search/filter-row scope accessories just to explain pinned state. 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. That same shared table-focus owner now also owns whitespace clearing: clicking a governed `data-summary-clear-surface` table-surface root may clear pinned scope, but row cells, group headers, inline detail, and explicit controls must not accidentally clear it. 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 without looking like a page refresh, and then still route through the shared reveal helper whenever the opened drawer would otherwise land below the fold. Same-route drawers must therefore scroll only enough to keep the row header plus the top of the inline detail visible, never hard-center the row just because the route state changed. +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. Pinned page/group/entity scope on workloads, infrastructure, or storage must stay row-first: the pinned row or group header is the visible scoped state, not a second strip or search-row widget. Page shells therefore must not reintroduce always-on scope banners, preview bars, page-local chips, breadcrumbs, or search/filter-row scope accessories just to explain pinned state. 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. That same shared table-focus owner now also owns whitespace clearing: pinned scope may clear only from governed neutral interaction-surface space, with page owners binding a broader clear-surface root separately from the row-lookup table root when needed. Row cells, group headers, inline detail, summary cards, and explicit controls must not accidentally clear pinned scope, while governed table/card clear surfaces must still allow real user clicks on neutral whitespace to clear it. 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 without looking like a page refresh, and then still route through the shared reveal helper whenever the opened drawer would otherwise land below the fold. Same-route drawers must therefore scroll only enough to keep the row header plus the top of the inline detail visible, never hard-center the row just because the route state changed. Shared summary-linked rows and group headers must also route their preview semantics through `frontend-modern/src/components/shared/summaryInteractionA11y.ts`. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 1ee55ca9e..1a4863a8f 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -166,7 +166,7 @@ regression protection. 24. Extend dashboard shell rendering through `frontend-modern/src/components/Dashboard/DashboardStateCards.tsx`, `frontend-modern/src/components/Dashboard/DashboardWorkloadTable.tsx`, and `frontend-modern/src/components/Dashboard/DashboardStatsStrip.tsx` rather than accreting loading cards, workload table markup, or stats-strip presentation back into `frontend-modern/src/components/Dashboard/Dashboard.tsx` 25. Extend dashboard workload table shell ownership through `frontend-modern/src/components/Dashboard/WorkloadTableHeader.tsx` and `frontend-modern/src/components/Dashboard/WorkloadPanel.tsx` rather than rebuilding sortable header markup, grouped node rows, row expansion, or guest-drawer rendering inside `frontend-modern/src/components/Dashboard/DashboardWorkloadTable.tsx` 26. Keep long-range workload chart capping time-proportional across `frontend-modern/src/components/Workloads/WorkloadsSummary.tsx`, `frontend-modern/src/api/charts.ts`, and `internal/api/router.go`: when the workload hot path caps mixed-cadence history for top cards, it must bucket by time window rather than raw point index so 7-day and 30-day workload cards stay visually even without relaxing the protected payload budget. -27. Keep summary hover/focus and sticky-card behavior on shared hot paths: infrastructure, workloads, and storage summary shells must reuse one page/group/entity scope model plus `frontend-modern/src/components/shared/StickySummarySection.tsx` inside the app scroll shell instead of per-page scroll listeners or per-card hover derivations, so row scrubbing highlights all cards, workload group headers, infrastructure cluster headers, and storage pool-group headers scope the summary coherently, pinned group focus remains route-backed and reversible, and the hot path does not multiply render or scroll work. That hot path stays row-first rather than adding fallback chrome: the on-screen row or group header is the scoped state, and clearing belongs to governed `data-summary-clear-surface` table-surface roots instead of bolting one-off clear buttons or search-row widgets into each page shell. That same hot path must keep chart-backed summary-card geometry explicit and stable so hover rerenders, synchronized readouts, or idle header metadata cannot feed layout loops that grow or shrink the top cards over time. Recovery’s summary rail is not part of this interactive hot path; it may share summary-card framing, but it must remain non-interactive until a separately governed model says otherwise. +27. Keep summary hover/focus and sticky-card behavior on shared hot paths: infrastructure, workloads, and storage summary shells must reuse one page/group/entity scope model plus `frontend-modern/src/components/shared/StickySummarySection.tsx` inside the app scroll shell instead of per-page scroll listeners or per-card hover derivations, so row scrubbing highlights all cards, workload group headers, infrastructure cluster headers, and storage pool-group headers scope the summary coherently, pinned group focus remains route-backed and reversible, and the hot path does not multiply render or scroll work. That hot path stays row-first rather than adding fallback chrome: the on-screen row or group header is the scoped state, and clearing belongs to governed neutral interaction surfaces instead of bolting one-off clear buttons or search-row widgets into each page shell. The same hot path must therefore separate row-lookup roots from broader clear-surface roots when page whitespace should clear pinned scope. That same hot path must keep chart-backed summary-card geometry explicit and stable so hover rerenders, synchronized readouts, or idle header metadata cannot feed layout loops that grow or shrink the top cards over time. Recovery’s summary rail is not part of this interactive hot path; it may share summary-card framing, but it must remain non-interactive until a separately governed model says otherwise. The input path for that hot summary contract must stay shared too: `frontend-modern/src/components/shared/summaryInteractionA11y.ts` owns fine-pointer preview and focus-preview continuity, while diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index bd73bef0e..b232b3525 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -202,9 +202,10 @@ querying, and the operator-facing storage health presentation layer. leaving stale row-local IDs or storage-local hover branches on the page. Any page, group, or entity scope that becomes pinned through storage interaction must stay row-first: the pinned row or group remains the - visible scoped state, and clearing belongs to the governed storage content - surface root via `data-summary-clear-surface` rather than an extra storage- - local strip or search-row widget. + visible scoped state, and clearing belongs to governed neutral + interaction-surface whitespace rather than an extra storage-local strip or + search-row widget, with storage owning a broader clear-surface root + separately from the content-card row-lookup root. When that scope is a storage pool group, member pool rows should expose shared `data-summary-group-member-active="preview|pinned"` state so the grouped diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 83ce45639..a0c0f9ccd 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -216,9 +216,11 @@ assembly branch. query state, so pinned scope is shareable, reversible, and owned by the same route-backed summary contract as row focus. Infrastructure must stay row-first here: the pinned cluster header remains the visible scoped - state, and clearing belongs to the governed infrastructure table surface - root via `data-summary-clear-surface` rather than a search-row fallback - widget or a second scope/pinned pill inside the cluster row chrome. + state, and clearing belongs to governed neutral interaction-surface + whitespace rather than a search-row fallback widget or a second + scope/pinned pill inside the cluster row chrome, with infrastructure + owning a broader clear-surface root separately from the table row-lookup + root. 14. Keep infrastructure row emphasis on the shared frontend presentation contract. Host, PBS, and PMG table sections may decide whether a resource is contextually active, but they must expose that state through diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index 26a1beee7..03b497f7f 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -13,7 +13,7 @@ export function Dashboard(props: DashboardProps) { const state = useDashboardState(props); return ( -
+
- 0 - } +
- state.setWorkloadsSummaryCollapsed((collapsed) => !collapsed) - : undefined + 0 } - containerRuntimeFilter={state.containerRuntimeFilterConfig()} - hostFilter={state.hostFilterConfig()} - namespaceFilter={state.namespaceFilterConfig()} - platformFilter={state.platformFilterConfig()} - /> - + > +
+ state.setWorkloadsSummaryCollapsed((collapsed) => !collapsed) + : undefined + } + containerRuntimeFilter={state.containerRuntimeFilterConfig()} + hostFilter={state.hostFilterConfig()} + namespaceFilter={state.namespaceFilterConfig()} + platformFilter={state.platformFilterConfig()} + /> +
+ - 0 - } - > - - + 0 + } + > + + +
{ expect(dashboardSource).not.toContain('SummaryScopeBar'); expect(dashboardSource).not.toContain('searchTrailing={pinnedScopeFallback()}'); expect(dashboardSource).not.toContain('mobileTrailing={pinnedScopeFallback()}'); + expect(dashboardSource).toContain('setClearSurfaceRootRef'); expect(dashboardSource).toContain('setTableRootRef={state.setTableRootRef}'); + expect(dashboardSource).toContain('data-testid="workloads-interaction-surface"'); + expect(dashboardSource).toContain('data-summary-clear-ignore'); expect(dashboardWorkloadTableSource).toContain('data-summary-clear-surface'); expect(dashboardWorkloadTableSource).toContain('data-testid="workloads-table-surface"'); expect(dashboardFilterSource).not.toContain('const [filtersOpen, setFiltersOpen] ='); diff --git a/frontend-modern/src/components/Dashboard/useDashboardSelectionState.ts b/frontend-modern/src/components/Dashboard/useDashboardSelectionState.ts index 46f757f43..4df46d19c 100644 --- a/frontend-modern/src/components/Dashboard/useDashboardSelectionState.ts +++ b/frontend-modern/src/components/Dashboard/useDashboardSelectionState.ts @@ -85,6 +85,10 @@ export function useDashboardSelectionState(options: UseDashboardSelectionStateOp summaryInteraction.setTableRootRef(element); }; + const setClearSurfaceRootRef = (element: HTMLDivElement | undefined) => { + summaryInteraction.setClearSurfaceRootRef(element); + }; + const setSelectedGuestIdState = (id: string | null) => { preserveScrollableAncestorVerticalOffset(tableWrapperRef(), () => { setSelectedGuestIdRaw(id); @@ -228,6 +232,7 @@ export function useDashboardSelectionState(options: UseDashboardSelectionStateOp revealedGuestId, selectedGuestId, setChartHoverSync: summaryInteraction.setChartHoverSync, + setClearSurfaceRootRef, setFocusedWorkloadGroupScope, setHoveredWorkloadGroupScope, setHoveredWorkloadId, diff --git a/frontend-modern/src/components/Dashboard/useDashboardState.ts b/frontend-modern/src/components/Dashboard/useDashboardState.ts index 6a69b3089..483746471 100644 --- a/frontend-modern/src/components/Dashboard/useDashboardState.ts +++ b/frontend-modern/src/components/Dashboard/useDashboardState.ts @@ -220,6 +220,7 @@ export function useDashboardState(props: DashboardProps) { revealedGuestId, selectedGuestId, setChartHoverSync, + setClearSurfaceRootRef, setFocusedWorkloadGroupScope, setHoveredWorkloadGroupScope, setHoveredWorkloadId, @@ -321,6 +322,7 @@ export function useDashboardState(props: DashboardProps) { selectedNode, setContainerRuntime, setChartHoverSync, + setClearSurfaceRootRef, setFocusedWorkloadGroupScope, setGroupingMode, setHoveredWorkloadGroupScope, diff --git a/frontend-modern/src/components/Storage/Storage.tsx b/frontend-modern/src/components/Storage/Storage.tsx index 169df6c53..fa7ec8ffd 100644 --- a/frontend-modern/src/components/Storage/Storage.tsx +++ b/frontend-modern/src/components/Storage/Storage.tsx @@ -57,6 +57,7 @@ const Storage: Component = () => { jumpToActiveStorageRow, selectedDiskId, setChartHoverSync, + setClearSurfaceRootRef, setFocusedStorageGroupScope, setHoveredStorageGroupScope, setHoveredStorageResourceId, @@ -66,7 +67,7 @@ const Storage: Component = () => { } = useStoragePageModel(); return ( -
+
filteredRecords().length} @@ -91,60 +92,68 @@ const Storage: Component = () => { isCephRecord={isStorageRecordCeph} /> - +
+
+ +
- + - + +
); }; diff --git a/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx b/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx index 37a6872f2..636183cd6 100644 --- a/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx @@ -815,7 +815,7 @@ describe('Storage', () => { ).toBeTruthy(); }); - const clearSurface = screen.getByTestId('storage-content-surface'); + const clearSurface = screen.getByTestId('storage-interaction-surface'); expect(clearSurface).not.toBeNull(); fireEvent.click(clearSurface); diff --git a/frontend-modern/src/components/Storage/useStoragePageModel.ts b/frontend-modern/src/components/Storage/useStoragePageModel.ts index e12af6a33..c678c5624 100644 --- a/frontend-modern/src/components/Storage/useStoragePageModel.ts +++ b/frontend-modern/src/components/Storage/useStoragePageModel.ts @@ -410,6 +410,7 @@ export const useStoragePageModel = () => { hoveredSummaryStorageGroupScope: hoveredStorageGroupScope, selectedDiskId, setChartHoverSync: summaryInteraction.setChartHoverSync, + setClearSurfaceRootRef: summaryInteraction.setClearSurfaceRootRef, setFocusedStorageGroupScope, setHoveredStorageGroupScope, setHoveredStorageResourceId, diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index c9aee2570..8a2a8fefd 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -313,6 +313,8 @@ describe('shared primitive guardrails', () => { expect(summaryTableFocusSource).toContain('revealInlineDetailInViewport'); expect(summaryTableFocusSource).toContain('MutationObserver'); expect(summaryTableFocusSource).toContain('clearPinnedScope?: () => void;'); + expect(summaryTableFocusSource).toContain('setClearSurfaceRootRef'); + expect(summaryTableFocusSource).toContain('[data-summary-clear-ignore]'); expect(summaryTableFocusSource).toContain("target.closest('[data-summary-clear-surface]')"); expect(summaryTableFocusSource).toContain('querySelector('); expect(summaryTableFocusSource).toContain( diff --git a/frontend-modern/src/components/shared/__tests__/summaryTableFocus.test.tsx b/frontend-modern/src/components/shared/__tests__/summaryTableFocus.test.tsx index 9d4edda7a..07f801172 100644 --- a/frontend-modern/src/components/shared/__tests__/summaryTableFocus.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/summaryTableFocus.test.tsx @@ -156,11 +156,12 @@ describe('useSummaryPageInteractionState', () => { it('clears pinned scope when operators click table whitespace on a clear surface', () => { const [focusedSeriesId] = createSignal('workload-a'); const clearPinnedScope = vi.fn(); + const clearRoot = document.createElement('div'); const root = document.createElement('div'); root.setAttribute('data-summary-clear-surface', ''); const filler = document.createElement('div'); - root.appendChild(filler); - document.body.appendChild(root); + clearRoot.append(root, filler); + document.body.appendChild(clearRoot); const { result } = renderHook(() => useSummaryPageInteractionState({ @@ -170,6 +171,7 @@ describe('useSummaryPageInteractionState', () => { ); result.setTableRootRef(root); + result.setClearSurfaceRootRef(clearRoot); filler.click(); expect(clearPinnedScope).toHaveBeenCalledTimes(1); @@ -178,12 +180,14 @@ describe('useSummaryPageInteractionState', () => { it('does not clear pinned scope when operators click an active summary row', () => { const [focusedSeriesId] = createSignal('workload-a'); const clearPinnedScope = vi.fn(); + const clearRoot = document.createElement('div'); const root = document.createElement('div'); root.setAttribute('data-summary-clear-surface', ''); const row = document.createElement('div'); row.setAttribute('data-summary-series-id', 'workload-a'); + clearRoot.append(root); root.appendChild(row); - document.body.appendChild(root); + document.body.appendChild(clearRoot); const { result } = renderHook(() => useSummaryPageInteractionState({ @@ -193,8 +197,35 @@ describe('useSummaryPageInteractionState', () => { ); result.setTableRootRef(root); + result.setClearSurfaceRootRef(clearRoot); row.click(); expect(clearPinnedScope).not.toHaveBeenCalled(); }); + + it('does not clear pinned scope when operators click ignored controls inside the clear root', () => { + const [focusedGroupId] = createSignal('group-a'); + const clearPinnedScope = vi.fn(); + const clearRoot = document.createElement('div'); + const root = document.createElement('div'); + const controls = document.createElement('div'); + const input = document.createElement('input'); + controls.setAttribute('data-summary-clear-ignore', ''); + controls.appendChild(input); + clearRoot.append(controls, root); + document.body.appendChild(clearRoot); + + const { result } = renderHook(() => + useSummaryPageInteractionState({ + clearPinnedScope, + focusedGroupId, + }), + ); + + result.setTableRootRef(root); + result.setClearSurfaceRootRef(clearRoot); + input.click(); + + expect(clearPinnedScope).not.toHaveBeenCalled(); + }); }); diff --git a/frontend-modern/src/components/shared/summaryTableFocus.ts b/frontend-modern/src/components/shared/summaryTableFocus.ts index 31224efe4..3121970b0 100644 --- a/frontend-modern/src/components/shared/summaryTableFocus.ts +++ b/frontend-modern/src/components/shared/summaryTableFocus.ts @@ -28,6 +28,7 @@ const SUMMARY_CLEAR_IGNORE_SELECTOR = [ '[role="button"]', '[role="link"]', '[role="menuitem"]', + '[data-summary-clear-ignore]', '[data-summary-series-id]', '[data-summary-group-id]', '[data-inline-detail-for]', @@ -74,6 +75,7 @@ export interface UseSummaryTableFocusBridgeOptions { export function useSummaryTableFocusBridge(options: UseSummaryTableFocusBridgeOptions) { const [tableRoot, setTableRoot] = createSignal(null); + const [clearSurfaceRoot, setClearSurfaceRoot] = createSignal(null); const [viewportVersion, setViewportVersion] = createSignal(0); const focusedSeriesId = options.focusedSeriesId ?? (() => null); const focusedGroupId = options.focusedGroupId ?? (() => null); @@ -130,8 +132,9 @@ export function useSummaryTableFocusBridge(options: UseSummaryTableFocusBridgeOp }); createEffect(() => { - const root = tableRoot(); - if (!root || !options.clearPinnedScope) { + const root = clearSurfaceRoot() ?? tableRoot(); + const lookupRoot = tableRoot(); + if (!root || !lookupRoot || !options.clearPinnedScope) { return; } @@ -143,10 +146,10 @@ export function useSummaryTableFocusBridge(options: UseSummaryTableFocusBridgeOp if (!(target instanceof HTMLElement) || !root.contains(target)) { return; } - if (!target.closest('[data-summary-clear-surface]')) { + if (target.closest(SUMMARY_CLEAR_IGNORE_SELECTOR)) { return; } - if (target.closest(SUMMARY_CLEAR_IGNORE_SELECTOR)) { + if (lookupRoot.contains(target) && !target.closest('[data-summary-clear-surface]')) { return; } options.clearPinnedScope?.(); @@ -345,6 +348,7 @@ export function useSummaryTableFocusBridge(options: UseSummaryTableFocusBridgeOp activeRow, isActiveRowVisible, jumpToActiveRow, + setClearSurfaceRootRef: (element: HTMLElement | undefined) => setClearSurfaceRoot(element ?? null), setTableRootRef: (element: HTMLElement | undefined) => setTableRoot(element ?? null), shouldShowJumpToActiveRow, } as const; @@ -409,6 +413,7 @@ export function useSummaryPageInteractionState(options: UseSummaryPageInteractio activeSeriesId, chartHoverSync, jumpToActiveRow: tableFocus.jumpToActiveRow, + setClearSurfaceRootRef: tableFocus.setClearSurfaceRootRef, setChartHoverSync, setTableRootRef: tableFocus.setTableRootRef, shouldShowJumpToActiveRow: tableFocus.shouldShowJumpToActiveRow, diff --git a/frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx b/frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx index 67acb35bf..ab5b30924 100644 --- a/frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx +++ b/frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx @@ -71,6 +71,7 @@ export function InfrastructurePageSurface() { filteredResources, hasFilteredResources, setChartHoverSync, + setSummaryClearSurfaceRootRef, setSummaryTableRootRef, shouldShowJumpToActiveResourceRow, } = useInfrastructurePageState(); @@ -160,7 +161,13 @@ export function InfrastructurePageSurface() { +
+
+
+
diff --git a/frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts b/frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts index 0436afb44..76bbc828c 100644 --- a/frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts +++ b/frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts @@ -50,7 +50,10 @@ describe('InfrastructurePageSurface guardrails', () => { expect(infrastructurePageSurfaceSource).toContain('hoveredSummaryGroupScope={hoveredSummaryResourceGroupScope()}'); expect(infrastructurePageSurfaceSource).toContain('focusedSummaryGroupScope={focusedSummaryResourceGroupScope()}'); expect(infrastructurePageSurfaceSource).toContain('onGroupHoverChange={setHoveredResourceGroupScope}'); + expect(infrastructurePageSurfaceSource).toContain('setSummaryClearSurfaceRootRef'); expect(infrastructurePageSurfaceSource).toContain('setTableRootRef={setSummaryTableRootRef}'); + expect(infrastructurePageSurfaceSource).toContain('data-testid="infrastructure-interaction-surface"'); + expect(infrastructurePageSurfaceSource).toContain('data-summary-clear-ignore'); expect(infrastructurePageSurfaceSource).not.toContain('SummaryScopeBar'); expect(infrastructurePageSurfaceSource).not.toContain('searchTrailing={pinnedScopeFallback()}'); expect(infrastructurePageSurfaceSource).not.toContain('mobileTrailing={pinnedScopeFallback()}'); @@ -63,6 +66,7 @@ describe('InfrastructurePageSurface guardrails', () => { expect(infrastructurePageStateSource).toContain('clearPinnedSummaryScope'); expect(infrastructurePageStateSource).toContain('setHoveredResourceGroupScope'); expect(infrastructurePageStateSource).toContain('jumpToActiveResourceRow'); + expect(infrastructurePageStateSource).toContain('setSummaryClearSurfaceRootRef'); expect(infrastructurePageStateSource).toContain('setSummaryTableRootRef'); expect(infrastructurePageStateSource).toContain('shouldShowJumpToActiveResourceRow'); expect(infrastructurePageStateSource).toContain('preserveScrollableAncestorVerticalOffset'); diff --git a/frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts b/frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts index 42cbf72d0..cd6be70e8 100644 --- a/frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts +++ b/frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts @@ -121,6 +121,10 @@ export function useInfrastructurePageState() { summaryInteraction.setTableRootRef(element); }; + const setSummaryClearSurfaceRootRef = (element: HTMLDivElement | undefined) => { + summaryInteraction.setClearSurfaceRootRef(element); + }; + const preserveTableScrollAnchor = (apply: () => void) => { preserveScrollableAncestorVerticalOffset(tableRootRef(), apply); }; @@ -245,6 +249,7 @@ export function useInfrastructurePageState() { setExpandedResourceId, setChartHoverSync: summaryInteraction.setChartHoverSync, setHoveredResourceGroupScope, + setSummaryClearSurfaceRootRef, setSummaryTableRootRef, shouldShowJumpToActiveResourceRow: summaryInteraction.shouldShowJumpToActiveRow, setFocusedResourceGroupId, diff --git a/tests/integration/tests/48-summary-hover-selection.spec.ts b/tests/integration/tests/48-summary-hover-selection.spec.ts index 52399924f..8ce1501e4 100644 --- a/tests/integration/tests/48-summary-hover-selection.spec.ts +++ b/tests/integration/tests/48-summary-hover-selection.spec.ts @@ -94,6 +94,62 @@ async function readSummaryHighlightCount( ); } +async function findNeutralClearPoint( + page: import("@playwright/test").Page, + surfaceTestId: string, + tableTestId: string, +): Promise<{ x: number; y: number }> { + return page.evaluate( + ({ surfaceTestId, tableTestId }) => { + const surface = document.querySelector(`[data-testid="${surfaceTestId}"]`); + const table = document.querySelector(`[data-testid="${tableTestId}"]`); + if (!surface) { + throw new Error(`Missing surface ${surfaceTestId}`); + } + if (!table) { + throw new Error(`Missing table ${tableTestId}`); + } + + const ignoreSelector = [ + 'button', + 'a', + 'input', + 'select', + 'textarea', + 'label', + 'summary', + '[role="button"]', + '[role="link"]', + '[role="menuitem"]', + '[data-summary-clear-ignore]', + '[data-summary-series-id]', + '[data-summary-group-id]', + '[data-inline-detail-for]', + ].join(', '); + + const surfaceRect = surface.getBoundingClientRect(); + for (let y = surfaceRect.top + 12; y <= surfaceRect.bottom - 12; y += 12) { + for (let x = surfaceRect.left + 12; x <= surfaceRect.right - 12; x += 12) { + const target = document.elementFromPoint(x, y); + if (!(target instanceof HTMLElement) || !surface.contains(target)) { + continue; + } + if (target.closest(ignoreSelector)) { + continue; + } + if (table.contains(target) && !target.closest('[data-summary-clear-surface]')) { + continue; + } + return { x, y }; + } + } + + throw new Error(`No neutral clear point found inside ${surfaceTestId}`); + }, + { surfaceTestId, tableTestId }, + ); +} + async function hoverRowUntilSummaryHighlights( page: import("@playwright/test").Page, rows: import("@playwright/test").Locator, @@ -1000,13 +1056,15 @@ test.describe.serial("Summary hover selection", () => { { path: "/workloads", summaryTestId: "workloads-summary", - clearSurfaceTestId: "workloads-table-surface", + interactionSurfaceTestId: "workloads-interaction-surface", + tableSurfaceTestId: "workloads-table-surface", tableRowSelector: "tr[data-guest-id]", }, { path: "/infrastructure", summaryTestId: "infrastructure-summary", - clearSurfaceTestId: "infrastructure-table-surface", + interactionSurfaceTestId: "infrastructure-interaction-surface", + tableSurfaceTestId: "infrastructure-table-surface", tableRowSelector: "tr[data-summary-series-id]", }, ] as const) { @@ -1114,9 +1172,12 @@ test.describe.serial("Summary hover selection", () => { .toBe(true); await expect(page.getByText("Pinned to")).toHaveCount(0); - await page.getByTestId(surface.clearSurfaceTestId).evaluate((element) => { - element.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); + const clearPoint = await findNeutralClearPoint( + page, + surface.interactionSurfaceTestId, + surface.tableSurfaceTestId, + ); + await page.mouse.click(clearPoint.x, clearPoint.y); await expect .poll(() => new URL(page.url()).searchParams.get("summaryGroup")) .toBeNull(); @@ -1141,7 +1202,7 @@ test.describe.serial("Summary hover selection", () => { await page.goto("/storage?group=node", { waitUntil: "domcontentloaded" }); const storageSummary = page.getByTestId("storage-summary"); - const storageContentSurface = page.getByTestId("storage-content-surface"); + const storageInteractionSurface = page.getByTestId("storage-interaction-surface"); await expect(storageSummary).toBeVisible(); await expect(page.getByText("Pinned to")).toHaveCount(0); @@ -1241,9 +1302,13 @@ test.describe.serial("Summary hover selection", () => { .toBe(true); await expect(page.getByText("Pinned to")).toHaveCount(0); - await storageContentSurface.evaluate((element) => { - element.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); + const clearPoint = await findNeutralClearPoint( + page, + "storage-interaction-surface", + "storage-content-surface", + ); + await expect(storageInteractionSurface).toBeVisible(); + await page.mouse.click(clearPoint.x, clearPoint.y); await expect(page.locator('tr[data-summary-group-member-active="pinned"]')).toHaveCount(0); await expect .poll(() => new URL(page.url()).searchParams.get("summaryGroup"))