fix(frontend): clear pinned scope from neutral interaction surfaces

This commit is contained in:
rcourtman 2026-04-03 00:36:06 +01:00
parent fb4f81dcf2
commit 3300ece8dd
18 changed files with 310 additions and 158 deletions

View file

@ -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`.

View file

@ -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. Recoverys 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. Recoverys 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

View file

@ -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

View file

@ -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

View file

@ -13,7 +13,7 @@ export function Dashboard(props: DashboardProps) {
const state = useDashboardState(props);
return (
<div class="space-y-3">
<div class="space-y-3" data-testid="workloads-page">
<Show when={state.isWorkloadsRoute() && !state.workloadsSummaryCollapsed()}>
<StickySummarySection>
<WorkloadsSummary
@ -60,88 +60,96 @@ export function Dashboard(props: DashboardProps) {
workloads={state.workloads}
/>
<Show
when={
!state.kioskMode() &&
state.surfaceConnected() &&
state.surfaceInitialDataReceived() &&
state.allGuests().length > 0
}
<div
ref={state.setClearSurfaceRootRef}
class="space-y-3"
data-testid="workloads-interaction-surface"
>
<DashboardFilter
search={state.search}
setSearch={state.setSearch}
viewMode={state.viewMode}
setViewMode={state.setViewMode}
statusMode={state.statusMode}
setStatusMode={state.setStatusMode}
groupingMode={state.groupingMode}
setGroupingMode={state.setGroupingMode}
setSortKey={state.setSortKey}
setSortDirection={state.setSortDirection}
onBeforeAutoFocus={state.handleBeforeAutoFocus}
columnVisibility={state.dashboardFilterColumnVisibility()}
chartsCollapsed={state.isWorkloadsRoute() ? state.workloadsSummaryCollapsed : undefined}
onChartsToggle={
state.isWorkloadsRoute()
? () => state.setWorkloadsSummaryCollapsed((collapsed) => !collapsed)
: undefined
<Show
when={
!state.kioskMode() &&
state.surfaceConnected() &&
state.surfaceInitialDataReceived() &&
state.allGuests().length > 0
}
containerRuntimeFilter={state.containerRuntimeFilterConfig()}
hostFilter={state.hostFilterConfig()}
namespaceFilter={state.namespaceFilterConfig()}
platformFilter={state.platformFilterConfig()}
/>
</Show>
>
<div data-summary-clear-ignore>
<DashboardFilter
search={state.search}
setSearch={state.setSearch}
viewMode={state.viewMode}
setViewMode={state.setViewMode}
statusMode={state.statusMode}
setStatusMode={state.setStatusMode}
groupingMode={state.groupingMode}
setGroupingMode={state.setGroupingMode}
setSortKey={state.setSortKey}
setSortDirection={state.setSortDirection}
onBeforeAutoFocus={state.handleBeforeAutoFocus}
columnVisibility={state.dashboardFilterColumnVisibility()}
chartsCollapsed={state.isWorkloadsRoute() ? state.workloadsSummaryCollapsed : undefined}
onChartsToggle={
state.isWorkloadsRoute()
? () => state.setWorkloadsSummaryCollapsed((collapsed) => !collapsed)
: undefined
}
containerRuntimeFilter={state.containerRuntimeFilterConfig()}
hostFilter={state.hostFilterConfig()}
namespaceFilter={state.namespaceFilterConfig()}
platformFilter={state.platformFilterConfig()}
/>
</div>
</Show>
<Show
when={
state.surfaceConnected() &&
state.surfaceInitialDataReceived() &&
state.filteredGuests().length > 0
}
>
<DashboardWorkloadTable
activeAlerts={state.activeAlerts}
alertsEnabled={state.alertsEnabled}
bottomSpacerHeight={state.bottomSpacerHeight}
getGroupLabel={state.getGroupLabel}
groupedGuests={state.groupedGuests}
groupedWindowing={state.groupedWindowing}
guestMetadata={state.guestMetadata}
guestParentNodeMap={state.guestParentNodeMap}
groupingMode={state.groupingMode}
handleCustomUrlUpdate={state.handleCustomUrlUpdate}
handleSort={state.handleSort}
handleTagClick={state.handleTagClick}
activeSummaryWorkloadGroupScope={state.activeSummaryWorkloadGroupScope}
activeSummaryWorkloadId={state.activeSummaryWorkloadId}
focusedSummaryWorkloadGroupScope={state.focusedSummaryWorkloadGroupScope}
focusedSummaryWorkloadGroupId={state.focusedSummaryWorkloadGroupId}
hoveredSummaryWorkloadGroupScope={state.hoveredSummaryWorkloadGroupScope}
isMobile={state.isMobile}
mobileVisibleColumnIds={state.mobileVisibleColumnIds}
mobileVisibleColumns={state.mobileVisibleColumns}
nodeByInstance={state.nodeByInstance}
search={state.search}
selectedGuestId={state.selectedGuestId}
setFocusedWorkloadGroupScope={state.setFocusedWorkloadGroupScope}
setHoveredWorkloadGroupScope={state.setHoveredWorkloadGroupScope}
setHoveredWorkloadId={state.setHoveredWorkloadId}
setSelectedGuestId={state.setSelectedGuestId}
setTableRootRef={state.setTableRootRef}
setTableBodyRef={state.setTableBodyRef}
setTableWrapperRef={state.setTableWrapperRef}
sortDirection={state.sortDirection}
sortKey={state.sortKey}
topSpacerHeight={state.topSpacerHeight}
totalColumns={state.totalColumns}
visibleColumns={state.visibleColumns}
visibleGroupKeys={state.visibleGroupKeys}
windowedGroupedGuests={state.windowedGroupedGuests}
workloadIOEmphasis={state.workloadIOEmphasis}
/>
</Show>
<Show
when={
state.surfaceConnected() &&
state.surfaceInitialDataReceived() &&
state.filteredGuests().length > 0
}
>
<DashboardWorkloadTable
activeAlerts={state.activeAlerts}
alertsEnabled={state.alertsEnabled}
bottomSpacerHeight={state.bottomSpacerHeight}
getGroupLabel={state.getGroupLabel}
groupedGuests={state.groupedGuests}
groupedWindowing={state.groupedWindowing}
guestMetadata={state.guestMetadata}
guestParentNodeMap={state.guestParentNodeMap}
groupingMode={state.groupingMode}
handleCustomUrlUpdate={state.handleCustomUrlUpdate}
handleSort={state.handleSort}
handleTagClick={state.handleTagClick}
activeSummaryWorkloadGroupScope={state.activeSummaryWorkloadGroupScope}
activeSummaryWorkloadId={state.activeSummaryWorkloadId}
focusedSummaryWorkloadGroupScope={state.focusedSummaryWorkloadGroupScope}
focusedSummaryWorkloadGroupId={state.focusedSummaryWorkloadGroupId}
hoveredSummaryWorkloadGroupScope={state.hoveredSummaryWorkloadGroupScope}
isMobile={state.isMobile}
mobileVisibleColumnIds={state.mobileVisibleColumnIds}
mobileVisibleColumns={state.mobileVisibleColumns}
nodeByInstance={state.nodeByInstance}
search={state.search}
selectedGuestId={state.selectedGuestId}
setFocusedWorkloadGroupScope={state.setFocusedWorkloadGroupScope}
setHoveredWorkloadGroupScope={state.setHoveredWorkloadGroupScope}
setHoveredWorkloadId={state.setHoveredWorkloadId}
setSelectedGuestId={state.setSelectedGuestId}
setTableRootRef={state.setTableRootRef}
setTableBodyRef={state.setTableBodyRef}
setTableWrapperRef={state.setTableWrapperRef}
sortDirection={state.sortDirection}
sortKey={state.sortKey}
topSpacerHeight={state.topSpacerHeight}
totalColumns={state.totalColumns}
visibleColumns={state.visibleColumns}
visibleGroupKeys={state.visibleGroupKeys}
windowedGroupedGuests={state.windowedGroupedGuests}
workloadIOEmphasis={state.workloadIOEmphasis}
/>
</Show>
</div>
<DashboardStatsStrip
connected={state.surfaceConnected}

View file

@ -723,7 +723,10 @@ describe('Dashboard performance contract', () => {
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] =');

View file

@ -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,

View file

@ -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,

View file

@ -57,6 +57,7 @@ const Storage: Component = () => {
jumpToActiveStorageRow,
selectedDiskId,
setChartHoverSync,
setClearSurfaceRootRef,
setFocusedStorageGroupScope,
setHoveredStorageGroupScope,
setHoveredStorageResourceId,
@ -66,7 +67,7 @@ const Storage: Component = () => {
} = useStoragePageModel();
return (
<div class="space-y-4">
<div class="space-y-4" data-testid="storage-page">
<StickySummarySection desktopOnly={false}>
<StoragePageSummary
filteredRecordCount={() => filteredRecords().length}
@ -91,60 +92,68 @@ const Storage: Component = () => {
isCephRecord={isStorageRecordCeph}
/>
<StoragePageControls
kioskMode={kioskMode}
view={view}
setView={setView}
search={search}
setSearch={setSearch}
groupBy={groupBy}
setGroupBy={setGroupBy}
sortKey={sortKey}
setSortKey={setSortKey}
sortDirection={sortDirection}
setSortDirection={setSortDirection}
statusFilter={storageFilterStatus}
setStatusFilter={setStorageFilterStatus}
sourceFilter={sourceFilter}
setSourceFilter={setSourceFilter}
sourceOptions={sourceFilterOptions}
nodeFilterOptions={nodeFilterOptions()}
selectedNodeId={selectedNodeId}
setSelectedNodeId={setSelectedNodeId}
storageFilterGroupBy={storageFilterGroupBy}
/>
<div
ref={setClearSurfaceRootRef}
class="space-y-4"
data-testid="storage-interaction-surface"
>
<div data-summary-clear-ignore>
<StoragePageControls
kioskMode={kioskMode}
view={view}
setView={setView}
search={search}
setSearch={setSearch}
groupBy={groupBy}
setGroupBy={setGroupBy}
sortKey={sortKey}
setSortKey={setSortKey}
sortDirection={sortDirection}
setSortDirection={setSortDirection}
statusFilter={storageFilterStatus}
setStatusFilter={setStorageFilterStatus}
sourceFilter={sourceFilter}
setSourceFilter={setSourceFilter}
sourceOptions={sourceFilterOptions}
nodeFilterOptions={nodeFilterOptions()}
selectedNodeId={selectedNodeId}
setSelectedNodeId={setSelectedNodeId}
storageFilterGroupBy={storageFilterGroupBy}
/>
</div>
<StoragePageBanners kind={activeBannerKind} reconnect={reconnect} />
<StoragePageBanners kind={activeBannerKind} reconnect={reconnect} />
<StorageContentCard
view={view}
physicalDisks={physicalDisks}
nodes={nodes}
selectedNodeId={selectedNodeId}
search={search}
groupedRecords={groupedRecords}
groupBy={groupBy}
expandedGroups={expandedGroups}
toggleGroup={toggleGroup}
expandedPoolId={expandedPoolId}
setExpandedPoolId={setExpandedPoolId}
nodeOnlineByLabel={nodeOnlineByLabel}
highlightedRecordId={highlightedRecordId}
getRecordAlertState={getRecordAlertState}
isLoadingPools={isLoadingPools}
activeSummaryGroupScope={activeSummaryStorageGroupScope}
hoveredSummaryGroupScope={hoveredSummaryStorageGroupScope}
focusedSummaryGroupScope={focusedSummaryStorageGroupScope}
focusedSummaryGroupId={focusedSummaryStorageGroupId}
onGroupFocusChange={setFocusedStorageGroupScope}
onGroupHoverChange={setHoveredStorageGroupScope}
highlightedSummaryResourceId={activeSummaryStorageResourceId}
hoveredStorageResourceId={hoveredStorageResourceId}
setTableRootRef={setSummaryTableRootRef}
setHoveredStorageResourceId={setHoveredStorageResourceId}
selectedDiskId={selectedDiskId}
setSelectedDiskId={setSelectedDiskId}
/>
<StorageContentCard
view={view}
physicalDisks={physicalDisks}
nodes={nodes}
selectedNodeId={selectedNodeId}
search={search}
groupedRecords={groupedRecords}
groupBy={groupBy}
expandedGroups={expandedGroups}
toggleGroup={toggleGroup}
expandedPoolId={expandedPoolId}
setExpandedPoolId={setExpandedPoolId}
nodeOnlineByLabel={nodeOnlineByLabel}
highlightedRecordId={highlightedRecordId}
getRecordAlertState={getRecordAlertState}
isLoadingPools={isLoadingPools}
activeSummaryGroupScope={activeSummaryStorageGroupScope}
hoveredSummaryGroupScope={hoveredSummaryStorageGroupScope}
focusedSummaryGroupScope={focusedSummaryStorageGroupScope}
focusedSummaryGroupId={focusedSummaryStorageGroupId}
onGroupFocusChange={setFocusedStorageGroupScope}
onGroupHoverChange={setHoveredStorageGroupScope}
highlightedSummaryResourceId={activeSummaryStorageResourceId}
hoveredStorageResourceId={hoveredStorageResourceId}
setTableRootRef={setSummaryTableRootRef}
setHoveredStorageResourceId={setHoveredStorageResourceId}
selectedDiskId={selectedDiskId}
setSelectedDiskId={setSelectedDiskId}
/>
</div>
</div>
);
};

View file

@ -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);

View file

@ -410,6 +410,7 @@ export const useStoragePageModel = () => {
hoveredSummaryStorageGroupScope: hoveredStorageGroupScope,
selectedDiskId,
setChartHoverSync: summaryInteraction.setChartHoverSync,
setClearSurfaceRootRef: summaryInteraction.setClearSurfaceRootRef,
setFocusedStorageGroupScope,
setHoveredStorageGroupScope,
setHoveredStorageResourceId,

View file

@ -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<HTMLElement>(');
expect(summaryTableFocusSource).toContain(

View file

@ -156,11 +156,12 @@ describe('useSummaryPageInteractionState', () => {
it('clears pinned scope when operators click table whitespace on a clear surface', () => {
const [focusedSeriesId] = createSignal<string | null>('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<string | null>('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<string | null>('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();
});
});

View file

@ -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<HTMLElement | null>(null);
const [clearSurfaceRoot, setClearSurfaceRoot] = createSignal<HTMLElement | null>(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,

View file

@ -71,6 +71,7 @@ export function InfrastructurePageSurface() {
filteredResources,
hasFilteredResources,
setChartHoverSync,
setSummaryClearSurfaceRootRef,
setSummaryTableRootRef,
shouldShowJumpToActiveResourceRow,
} = useInfrastructurePageState();
@ -160,7 +161,13 @@ export function InfrastructurePageSurface() {
</StickySummarySection>
</Show>
<div
ref={setSummaryClearSurfaceRootRef}
class="space-y-3"
data-testid="infrastructure-interaction-surface"
>
<Show when={!kioskMode()}>
<div data-summary-clear-ignore>
<Card padding="sm" class="mb-4">
<PageControls
search={
@ -293,6 +300,7 @@ export function InfrastructurePageSurface() {
/>
</PageControls>
</Card>
</div>
</Show>
<Show
@ -339,6 +347,7 @@ export function InfrastructurePageSurface() {
/>
</div>
</Show>
</div>
</div>
</Show>
</Show>

View file

@ -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');

View file

@ -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,

View file

@ -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<HTMLElement>(`[data-testid="${surfaceTestId}"]`);
const table = document.querySelector<HTMLElement>(`[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"))