mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
fix(ui): make Proxmox overview tables container-aware
Contract-Neutral: Responsive Proxmox Overview behavior and verification only; canonical subsystem ownership and public contracts are unchanged
This commit is contained in:
@@ -1,43 +1,82 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "3a24d9694f3bb4062170149d02e0eb8241cf398b",
|
||||
"verified_at": "2026-08-03T23:30:46Z",
|
||||
"base_sha": "46af83d5fd65443f07a465446093aa805cd0b3e3",
|
||||
"verified_at": "2026-08-04T00:12:38Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/GuestRowCells.tsx",
|
||||
"frontend-modern/src/components/Workloads/guestRowModel.tsx"
|
||||
"frontend-modern/src/components/Workloads/WorkloadsFilter.tsx",
|
||||
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx",
|
||||
"frontend-modern/src/components/Workloads/WorkloadsTable.tsx",
|
||||
"frontend-modern/src/components/Workloads/guestRowModel.tsx",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsState.ts",
|
||||
"frontend-modern/src/components/Workloads/workloadsFilterModel.ts",
|
||||
"frontend-modern/src/components/shared/columnPickerModel.ts",
|
||||
"frontend-modern/src/components/shared/useColumnPickerState.ts",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx",
|
||||
"frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts",
|
||||
"frontend-modern/src/hooks/useObservedElementWidth.ts",
|
||||
"frontend-modern/src/index.css"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/GuestRowCells.tsx": "f34d05f6731bacafb07613ac982ba621865e0f3e1db7baae42cff7ac994917d8",
|
||||
"frontend-modern/src/components/Workloads/guestRowModel.tsx": "6be3cc7e2958f3bd8ead420dac8467b11941b1037721e881a4f1e85e1f5c8cd1"
|
||||
"frontend-modern/src/components/Workloads/WorkloadsFilter.tsx": "d420344cdb8a8277f9868a4e838099efa050a470ed4c22840aec0df0fd910542",
|
||||
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "a2e4911a8974cbb03db55d7a58fca37582974257aa7f88771eb1d6f98205d44f",
|
||||
"frontend-modern/src/components/Workloads/WorkloadsTable.tsx": "6852af254cf0fac698d0481bd228186b7237108e7345085e6d48fc2923493d83",
|
||||
"frontend-modern/src/components/Workloads/guestRowModel.tsx": "4866bc3fd4ca22d73eade48321fefde6e6f2940c32ae3f98ade87a90c080254f",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts": "766ee0e188ad37e386049d37535f043d29fdf138fd0fc82c340c1817e9cafc14",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsState.ts": "86d9a97ecb10502e6baf7ca68a884db1fdd74afed1240e6b4ff5b35ee76e2d11",
|
||||
"frontend-modern/src/components/Workloads/workloadsFilterModel.ts": "bd418028233fc52e361a0428d859b77e8fc77f9f00dfa8baa1449577a506eb17",
|
||||
"frontend-modern/src/components/shared/columnPickerModel.ts": "a8c34404ae8da4030a126d2188c8f3486f6a699fbdce12531e4e15e97a806175",
|
||||
"frontend-modern/src/components/shared/useColumnPickerState.ts": "260020f896719b4fd1bed8d2511b3a47028753f822c3769a2fe7bd5a4be05d3e",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "bc7c1146577abd439ea21490c8d0ad38d4efbf030c9ebbd2d6ed0e225a2ed7bb",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "35f4e5511dba2782d3f2d32a84c470094f9290635953275991eda65d9dff16d6",
|
||||
"frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts": "8d70932ef914db8870c96f0f77a2fab2d39bb70081918b82bd5bbf0a0b9db437",
|
||||
"frontend-modern/src/hooks/useObservedElementWidth.ts": "cfb04bc6dde18060344b26d6c663fc4d3f147882323dc1cb1091ea3f03ac56f5",
|
||||
"frontend-modern/src/index.css": "7b2f6c80b1e8a75849b0633b41be1c74d6740d263982aa5f3f365a6eb733dc17"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox"
|
||||
"/proxmox/overview"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
{ "width": 375, "height": 812 },
|
||||
{ "width": 480, "height": 812 },
|
||||
{ "width": 640, "height": 900 },
|
||||
{ "width": 768, "height": 900 },
|
||||
{ "width": 800, "height": 900 },
|
||||
{ "width": 900, "height": 900 },
|
||||
{ "width": 1024, "height": 900 },
|
||||
{ "width": 1280, "height": 900 },
|
||||
{ "width": 1440, "height": 900 },
|
||||
{ "width": 1536, "height": 900 },
|
||||
{ "width": 1680, "height": 900 },
|
||||
{ "width": 1920, "height": 900 },
|
||||
{ "width": 2560, "height": 900 }
|
||||
],
|
||||
"states": [
|
||||
"Proxmox Overview grouped workload table against the live 7655 backend with 34 guests across delly, minipc and pi",
|
||||
"Avail column populated for probed guests and blank for unprobed guests such as iventoy, ollama and tails-anon",
|
||||
"Backup column with all 34 rows in the fresh state rendering a bare green shield with no drawn age and no pill chrome",
|
||||
"View preferences popover open with the Columns section expanded and the new Avail toggle listed",
|
||||
"Avail column hidden through the new toggle and then restored to the original hidden set"
|
||||
"Live Proxmox Overview against the local backend with the Nodes table and grouped VM/LXC workload inventory visible",
|
||||
"Automatic container-aware column stages from four essential host columns at narrow widths through the full ten-column host inventory at wide widths",
|
||||
"Automatic workload stages from five essential columns through the wide workload metrics and context columns",
|
||||
"Overview-only wide data workspace using the available monitor width while retaining normal text and control sizes",
|
||||
"All optional workload columns explicitly selected with a readable 1510px table floor and intentional horizontal scrolling",
|
||||
"Column picker with the renamed Reset action still visible after every optional column is selected",
|
||||
"Grouped and List workload modes at compact and wide resolutions",
|
||||
"Bars and Trends metric display modes",
|
||||
"Pulse Assistant open beside Overview at desktop widths",
|
||||
"Very narrow 375px and 480px layouts retaining readable essential columns through deliberate table-local scrolling"
|
||||
],
|
||||
"interactions": [
|
||||
"Signed in at 1440x900 and read the Proxmox Overview table through DOM assertions on the availability cells and the backup status badges",
|
||||
"Confirmed availability cells render bare latency such as 3ms, 1ms and 5ms with no trailing freshness suffix",
|
||||
"Confirmed all 34 fresh backup badges render empty text, carry the green shield colour, and have no rounded-full pill chrome, while their aria-label still reports the backup age",
|
||||
"Opened View then Columns, clicked the Avail checkbox off, and confirmed the availability header and every availability cell were removed and the preference persisted to workloadsHiddenColumns",
|
||||
"Clicked Avail back on and confirmed workloadsHiddenColumns returned to its original value of aiContext, os, ip",
|
||||
"Resized to 390x844 and confirmed both columns render unchanged with no horizontal document overflow",
|
||||
"Read console messages filtered to errors and found none across both viewports"
|
||||
"Returned from Storage to /proxmox/overview before implementation and kept all subsequent product testing scoped to Overview",
|
||||
"Resized the real in-app browser through 375, 480, 640, 768, 800, 900, 1024, 1280, 1440, 1536, 1680, 1920 and 2560 pixel widths",
|
||||
"Asserted both table wrapper overflow and every header's clientWidth versus scrollWidth at each final matrix width; found zero default overflow and zero clipped headers from 640px through 2560px",
|
||||
"Visually inspected screenshots at 900x812 and 1440x900, confirming readable labels, metric bars, host context and workload density",
|
||||
"Opened View and Columns, selected IP, AI Context, Tags, OS, Net I/O and Disk I/O, and confirmed all 15 columns render at a readable 1510px floor rather than being squeezed",
|
||||
"Resized the all-column table from 1536px to 1024px and confirmed the 1510px floor, readable 243px first cell and table-local 572px horizontal overflow",
|
||||
"Confirmed Reset remains available with zero hidden columns, then restored the responsive defaults and verified overflow returned to zero",
|
||||
"Hid Tags at 1680px, resized to 1024px and back, and confirmed the hidden choice persisted; showed Tags, resized to 1024px, and confirmed the explicit choice stayed visible with a readable 996px floor before resetting",
|
||||
"Switched to List mode across 1024, 1280, 1536, 1680, 1920 and 2560px, confirmed zero overflow, then restored Grouped mode",
|
||||
"Switched from Bars to Trends at 900px, confirmed both tables retained zero overflow, then restored Bars",
|
||||
"Opened Pulse Assistant and tested 1280, 1536, 1920 and 2560px; both tables adapted to the reduced measured container with zero overflow and no clipped headers",
|
||||
"Closed Pulse Assistant, closed the View menu, restored default columns, restored the normal browser viewport and left /proxmox/overview open"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -101,6 +101,13 @@ describe('App platform navigation admission', () => {
|
||||
});
|
||||
|
||||
describe('App architecture', () => {
|
||||
it('limits the wider shell treatment to marked data surfaces', () => {
|
||||
expect(appStylesSource).toContain(
|
||||
'.pulse-shell:has(.pulse-wide-data-surface):not(.pulse-shell--full-width)',
|
||||
);
|
||||
expect(appStylesSource).toContain('--pulse-shell-max-width: min(97vw, 1920px)');
|
||||
});
|
||||
|
||||
it('keeps App as the entry shell that delegates runtime and chrome ownership', () => {
|
||||
expect(appSource).toContain(
|
||||
"import { AppLayout, sessionHasSettingsAccess } from '@/AppLayout';",
|
||||
|
||||
@@ -354,6 +354,7 @@ export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
|
||||
isHidden={visibility().isColumnHidden}
|
||||
onToggle={visibility().onColumnToggle}
|
||||
onReset={visibility().onColumnReset}
|
||||
showReset={visibility().showReset}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -214,6 +214,7 @@ export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) {
|
||||
workloadMemoryDisplayBasis={state.workloadMemoryDisplayBasis}
|
||||
workloadMetricHistory={state.workloadMetricHistory}
|
||||
workloadTableLayoutMode={state.workloadTableLayoutMode}
|
||||
workloadTableMinimumWidth={state.workloadTableMinimumWidth}
|
||||
workloadTableVisibleColumnIds={state.workloadTableVisibleColumnIds}
|
||||
workloadTableVisibleColumns={state.workloadTableVisibleColumns}
|
||||
/>
|
||||
|
||||
@@ -58,6 +58,7 @@ type WorkloadsTableProps = Pick<
|
||||
| 'workloadMemoryDisplayBasis'
|
||||
| 'workloadMetricHistory'
|
||||
| 'workloadTableLayoutMode'
|
||||
| 'workloadTableMinimumWidth'
|
||||
| 'workloadTableVisibleColumnIds'
|
||||
| 'workloadTableVisibleColumns'
|
||||
>;
|
||||
@@ -74,6 +75,11 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
|
||||
<Table
|
||||
wrapperRef={props.setTableWrapperRef}
|
||||
class={`workload-table table-fixed ${props.isMobile() ? `workload-table--mobile ${WORKLOAD_TABLE_MOBILE_MIN_WIDTH_CLASS}` : 'workload-table--desktop min-w-full'}`}
|
||||
style={
|
||||
props.workloadTableMinimumWidth()
|
||||
? { 'min-width': `${props.workloadTableMinimumWidth()}px` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<colgroup>
|
||||
<For each={props.workloadTableVisibleColumns()}>
|
||||
|
||||
@@ -136,6 +136,8 @@ import {
|
||||
getGuestColumnStyle,
|
||||
getGuestColumnWidthStyle,
|
||||
getWorkloadTableLayoutMode,
|
||||
getWorkloadTableLayoutModeForContainer,
|
||||
getWorkloadTableReadableMinWidth,
|
||||
getWorkloadVisibleColumnsForLayout,
|
||||
type WorkloadIOEmphasis,
|
||||
} from '../guestRowModel';
|
||||
@@ -1142,6 +1144,25 @@ describe('GUEST_COLUMNS', () => {
|
||||
expect(getWorkloadTableLayoutMode(1536)).toBe('wide');
|
||||
});
|
||||
|
||||
it('maps workload table layout modes to the actual table container', () => {
|
||||
expect(getWorkloadTableLayoutModeForContainer(719)).toBe('mobile');
|
||||
expect(getWorkloadTableLayoutModeForContainer(720)).toBe('tablet');
|
||||
expect(getWorkloadTableLayoutModeForContainer(899)).toBe('tablet');
|
||||
expect(getWorkloadTableLayoutModeForContainer(900)).toBe('compact');
|
||||
expect(getWorkloadTableLayoutModeForContainer(1439)).toBe('compact');
|
||||
expect(getWorkloadTableLayoutModeForContainer(1440)).toBe('wide');
|
||||
});
|
||||
|
||||
it('adds a readable width floor only when an explicit column exceeds the layout', () => {
|
||||
const columns = GUEST_COLUMNS.filter((column) =>
|
||||
['name', 'cpu', 'memory', 'netIo'].includes(column.id),
|
||||
);
|
||||
|
||||
expect(getWorkloadTableReadableMinWidth(columns, 'compact', new Set(['netIo']))).toBe(630);
|
||||
expect(getWorkloadTableReadableMinWidth(columns, 'compact', new Set(['cpu']))).toBeNull();
|
||||
expect(getWorkloadTableReadableMinWidth(columns, 'wide', new Set(['netIo']))).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps CPU and memory fixed while allowing disk to be platform-scoped', () => {
|
||||
const nonToggleable = GUEST_COLUMNS.filter((c) => !c.toggleable);
|
||||
const ids = nonToggleable.map((c) => c.id);
|
||||
|
||||
+34
-3
@@ -69,12 +69,13 @@ describe('useWorkloadsControlsState', () => {
|
||||
expect(state.workloadTableVisibleColumnIds()).toContain('netIo');
|
||||
expect(menu.isColumnHidden('netIo')).toBe(false);
|
||||
expect(state.columnVisibility.hiddenColumns()).not.toContain('netIo');
|
||||
expect(state.workloadsFilterColumnVisibility().showReset).toBe(true);
|
||||
|
||||
// Second toggle unpins it; the column returns to its layout default
|
||||
// rather than becoming user-hidden on wide viewports too.
|
||||
// Second toggle hides it everywhere, so the same explicit choice has
|
||||
// the same meaning regardless of the current responsive stage.
|
||||
menu.onColumnToggle('netIo');
|
||||
expect(state.workloadTableVisibleColumnIds()).not.toContain('netIo');
|
||||
expect(state.columnVisibility.hiddenColumns()).not.toContain('netIo');
|
||||
expect(state.columnVisibility.hiddenColumns()).toContain('netIo');
|
||||
|
||||
// Layout-visible columns keep the plain hide/show semantics.
|
||||
menu.onColumnToggle('backup');
|
||||
@@ -87,6 +88,36 @@ describe('useWorkloadsControlsState', () => {
|
||||
expect(state.workloadTableVisibleColumnIds()).toContain('netIo');
|
||||
menu.onColumnReset();
|
||||
expect(state.workloadTableVisibleColumnIds()).not.toContain('netIo');
|
||||
expect(state.workloadsFilterColumnVisibility().showReset).toBe(false);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a user-shown column pinned when its measured container narrows', () => {
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
const [showFilters, setShowFilters] = createSignal(false);
|
||||
const [layoutWidth, setLayoutWidth] = createSignal(1500);
|
||||
const state = useWorkloadsControlsState({
|
||||
viewMode: () => 'all' as ViewMode,
|
||||
showFilters,
|
||||
setShowFilters,
|
||||
layoutWidth,
|
||||
});
|
||||
const menu = state.workloadsFilterColumnVisibility();
|
||||
|
||||
expect(state.workloadTableLayoutMode()).toBe('wide');
|
||||
menu.onColumnToggle('netIo');
|
||||
expect(state.workloadTableVisibleColumnIds()).not.toContain('netIo');
|
||||
menu.onColumnToggle('netIo');
|
||||
expect(state.workloadTableVisibleColumnIds()).toContain('netIo');
|
||||
|
||||
setLayoutWidth(1000);
|
||||
expect(state.workloadTableLayoutMode()).toBe('compact');
|
||||
expect(state.workloadTableVisibleColumnIds()).toContain('netIo');
|
||||
expect(state.workloadTableMinimumWidth()).toBeGreaterThan(1000);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ export const WORKLOAD_TABLE_TABLET_LAYOUT_WIDTH = 900;
|
||||
// a user explicitly pins (see forcedColumnIds); the breakpoint default must
|
||||
// fit, so wide waits until the shell can actually hold the full set.
|
||||
export const WORKLOAD_TABLE_WIDE_LAYOUT_WIDTH = 1536;
|
||||
export const WORKLOAD_TABLE_CONTAINER_MOBILE_WIDTH = 720;
|
||||
export const WORKLOAD_TABLE_CONTAINER_TABLET_WIDTH = 900;
|
||||
export const WORKLOAD_TABLE_CONTAINER_WIDE_WIDTH = 1440;
|
||||
|
||||
const WORKLOAD_TABLE_LAYOUT_ORDER: Record<WorkloadTableLayoutMode, number> = {
|
||||
mobile: 0,
|
||||
@@ -548,6 +551,13 @@ export const getWorkloadTableLayoutMode = (width: number): WorkloadTableLayoutMo
|
||||
return 'wide';
|
||||
};
|
||||
|
||||
export const getWorkloadTableLayoutModeForContainer = (width: number): WorkloadTableLayoutMode => {
|
||||
if (!Number.isFinite(width) || width < WORKLOAD_TABLE_CONTAINER_MOBILE_WIDTH) return 'mobile';
|
||||
if (width < WORKLOAD_TABLE_CONTAINER_TABLET_WIDTH) return 'tablet';
|
||||
if (width < WORKLOAD_TABLE_CONTAINER_WIDE_WIDTH) return 'compact';
|
||||
return 'wide';
|
||||
};
|
||||
|
||||
export const getWorkloadVisibleColumnsForLayout = (
|
||||
columns: ColumnDef[],
|
||||
layoutMode: WorkloadTableLayoutMode,
|
||||
@@ -559,6 +569,31 @@ export const getWorkloadVisibleColumnsForLayout = (
|
||||
});
|
||||
};
|
||||
|
||||
const parsePixelWidth = (value: string | undefined): number => {
|
||||
const match = value?.trim().match(/^(\d+(?:\.\d+)?)px$/);
|
||||
return match ? Number(match[1]) : 0;
|
||||
};
|
||||
|
||||
export const getWorkloadTableReadableMinWidth = (
|
||||
columns: readonly ColumnDef[],
|
||||
layoutMode: WorkloadTableLayoutMode,
|
||||
forcedColumnIds: ReadonlySet<string>,
|
||||
): number | null => {
|
||||
const layoutRank = WORKLOAD_TABLE_LAYOUT_ORDER[layoutMode];
|
||||
const hasForcedOverflowColumn = columns.some((column) => {
|
||||
if (!forcedColumnIds.has(column.id)) return false;
|
||||
const minimumLayout = WORKLOAD_COLUMN_MIN_LAYOUT[column.id] ?? 'wide';
|
||||
return WORKLOAD_TABLE_LAYOUT_ORDER[minimumLayout] > layoutRank;
|
||||
});
|
||||
if (!hasForcedOverflowColumn) return null;
|
||||
|
||||
const width = columns.reduce(
|
||||
(total, column) => total + parsePixelWidth(column.minWidth ?? column.width),
|
||||
0,
|
||||
);
|
||||
return width > 0 ? Math.ceil(width) : null;
|
||||
};
|
||||
|
||||
export const VIEW_MODE_COLUMNS: Record<ViewMode, Set<string> | null> = {
|
||||
all: new Set([
|
||||
'name',
|
||||
|
||||
@@ -12,6 +12,8 @@ import type { ViewMode } from '@/types/workloads';
|
||||
import {
|
||||
GUEST_COLUMNS,
|
||||
VIEW_MODE_COLUMNS,
|
||||
getWorkloadTableLayoutModeForContainer,
|
||||
getWorkloadTableReadableMinWidth,
|
||||
getWorkloadTableLayoutMode,
|
||||
getWorkloadVisibleColumnsForLayout,
|
||||
} from './guestRowModel';
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
} from './workloadMetricHistoryModel';
|
||||
|
||||
interface WorkloadsControlsStateOptions {
|
||||
layoutWidth?: Accessor<number | null | undefined>;
|
||||
forcedGroupingMode?: WorkloadsGroupingMode;
|
||||
defaultSortKey?: WorkloadsSortKey;
|
||||
statusModeStorageScope?: string;
|
||||
@@ -84,7 +87,12 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const breakpoint = useBreakpoint();
|
||||
const workloadTableLayoutMode = createMemo(() => getWorkloadTableLayoutMode(breakpoint.width()));
|
||||
const workloadTableLayoutMode = createMemo(() => {
|
||||
const measuredWidth = options.layoutWidth?.();
|
||||
return typeof measuredWidth === 'number' && measuredWidth > 0
|
||||
? getWorkloadTableLayoutModeForContainer(measuredWidth)
|
||||
: getWorkloadTableLayoutMode(breakpoint.width());
|
||||
});
|
||||
const isMobile = createMemo(() => workloadTableLayoutMode() === 'mobile');
|
||||
const [isSearchLocked, setIsSearchLocked] = createSignal(false);
|
||||
|
||||
@@ -215,12 +223,10 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
|
||||
['aiContext'],
|
||||
);
|
||||
|
||||
// Columns the user explicitly pinned into view while the responsive layout
|
||||
// had them gated off. Without this, the columns menu offered toggles (IP,
|
||||
// Tags, Net I/O, ...) that did nothing below the wide breakpoint, and
|
||||
// sub-1440px viewports had no path to data v5 reached via horizontal
|
||||
// scroll. Explicit intent beats the breakpoint default; the table wrapper
|
||||
// scrolls horizontally when the pinned columns no longer fit.
|
||||
// Columns the user explicitly chose to show. Keeping that intent separate
|
||||
// from the responsive defaults makes the choice stable across resizing:
|
||||
// automatic columns may disappear as space contracts, while an explicit
|
||||
// choice remains readable via horizontal scrolling when necessary.
|
||||
const [forcedColumnIds, setForcedColumnIds] = usePersistentSignal<string[]>(
|
||||
`${columnStorageKey}:forced`,
|
||||
[],
|
||||
@@ -264,6 +270,13 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
|
||||
const workloadTableVisibleColumnIds = createMemo(() =>
|
||||
workloadTableVisibleColumns().map((column) => column.id),
|
||||
);
|
||||
const workloadTableMinimumWidth = createMemo(() =>
|
||||
getWorkloadTableReadableMinWidth(
|
||||
workloadTableVisibleColumns(),
|
||||
workloadTableLayoutMode(),
|
||||
forcedColumnIdSet(),
|
||||
),
|
||||
);
|
||||
const totalColumns = createMemo(() => workloadTableVisibleColumns().length);
|
||||
|
||||
const handleSort = (key: WorkloadsSortKey) => {
|
||||
@@ -340,28 +353,21 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
|
||||
}
|
||||
};
|
||||
|
||||
// Menu checkbox state mirrors what the table actually renders: a column the
|
||||
// layout gates off reads as hidden even when the user never hid it. Toggling
|
||||
// such a column pins it into view (or unpins it) instead of flipping a
|
||||
// user-hidden flag the user cannot see the effect of.
|
||||
// Menu checkbox state mirrors what the table actually renders. Showing a
|
||||
// column records explicit intent; hiding it removes that pin and applies the
|
||||
// same preference at every responsive stage.
|
||||
const isColumnHiddenForMenu = (id: string): boolean =>
|
||||
columnVisibility.isHiddenByUser(id) ||
|
||||
(layoutHiddenColumnIds().has(id) && !forcedColumnIdSet().has(id));
|
||||
|
||||
const handleColumnToggle = (id: string): void => {
|
||||
if (!layoutHiddenColumnIds().has(id)) {
|
||||
if (!isColumnHiddenForMenu(id)) {
|
||||
setForcedColumnIds(forcedColumnIds().filter((existing) => existing !== id));
|
||||
columnVisibility.toggle(id);
|
||||
columnVisibility.hide(id);
|
||||
return;
|
||||
}
|
||||
if (forcedColumnIdSet().has(id)) {
|
||||
setForcedColumnIds(forcedColumnIds().filter((existing) => existing !== id));
|
||||
return;
|
||||
}
|
||||
setForcedColumnIds([...forcedColumnIds(), id]);
|
||||
if (columnVisibility.isHiddenByUser(id)) {
|
||||
columnVisibility.toggle(id);
|
||||
}
|
||||
columnVisibility.show(id);
|
||||
if (!forcedColumnIdSet().has(id)) setForcedColumnIds([...forcedColumnIds(), id]);
|
||||
};
|
||||
|
||||
const handleColumnReset = (): void => {
|
||||
@@ -374,6 +380,7 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
|
||||
isColumnHidden: isColumnHiddenForMenu,
|
||||
onColumnToggle: handleColumnToggle,
|
||||
onColumnReset: handleColumnReset,
|
||||
showReset: forcedColumnIds().length > 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -402,6 +409,7 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
|
||||
workloadTableVisibleColumnIds,
|
||||
workloadTableVisibleColumns,
|
||||
workloadTableLayoutMode,
|
||||
workloadTableMinimumWidth,
|
||||
setWorkloadMetricHistoryRange,
|
||||
setWorkloadMetricDisplayMode,
|
||||
} as const;
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface WorkloadsSurfaceProps {
|
||||
vms: VM[];
|
||||
containers: Container[];
|
||||
nodes: Node[];
|
||||
layoutWidth?: Accessor<number | null | undefined>;
|
||||
useWorkloads?: boolean;
|
||||
forcedPlatform?: string;
|
||||
forcedViewMode?: ViewMode;
|
||||
@@ -271,6 +272,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
||||
workloadTableVisibleColumnIds,
|
||||
workloadTableVisibleColumns,
|
||||
workloadTableLayoutMode,
|
||||
workloadTableMinimumWidth,
|
||||
workloadMetricDisplayMode,
|
||||
workloadMetricHistoryRange,
|
||||
setWorkloadMetricDisplayMode,
|
||||
@@ -286,6 +288,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
||||
columnVisibilityStorageScope: props.columnVisibilityStorageScope,
|
||||
additionalDefaultHiddenColumnIds: props.additionalDefaultHiddenColumnIds,
|
||||
columnLabelOverrides: props.columnLabelOverrides,
|
||||
layoutWidth: props.layoutWidth,
|
||||
setShowFilters,
|
||||
showFilters,
|
||||
viewMode: effectiveViewMode,
|
||||
@@ -585,6 +588,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
||||
workloadTableVisibleColumnIds,
|
||||
workloadTableVisibleColumns,
|
||||
workloadTableLayoutMode,
|
||||
workloadTableMinimumWidth,
|
||||
workloadNodeOptions,
|
||||
workloads,
|
||||
workloadInventoryIssues,
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface WorkloadsFilterProps {
|
||||
isColumnHidden: (id: string) => boolean;
|
||||
onColumnToggle: (id: string) => void;
|
||||
onColumnReset?: () => void;
|
||||
showReset?: boolean;
|
||||
};
|
||||
hostFilter?: WorkloadsToolbarFilterConfig;
|
||||
platformFilter?: WorkloadsToolbarFilterConfig;
|
||||
|
||||
@@ -95,6 +95,24 @@ describe('ColumnPicker', () => {
|
||||
expect(screen.getByText('2 hidden')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps reset available for explicit overrides even when no column is hidden', async () => {
|
||||
const onReset = vi.fn();
|
||||
render(() => (
|
||||
<ColumnPicker
|
||||
columns={[{ id: 'subject', label: 'Subject' }]}
|
||||
isHidden={() => false}
|
||||
onToggle={vi.fn()}
|
||||
onReset={onReset}
|
||||
showReset
|
||||
/>
|
||||
));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /columns/i }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Reset' }));
|
||||
|
||||
expect(onReset).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('closes when the user clicks outside the open picker', async () => {
|
||||
render(() => (
|
||||
<ColumnPicker
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ColumnDef } from '@/hooks/useColumnVisibility';
|
||||
export const COLUMN_PICKER_BUTTON_LABEL = 'Columns';
|
||||
export const COLUMN_PICKER_BUTTON_TITLE = 'Choose which columns to display';
|
||||
export const COLUMN_PICKER_PANEL_TITLE = 'Show Columns';
|
||||
export const COLUMN_PICKER_RESET_LABEL = 'Show all';
|
||||
export const COLUMN_PICKER_RESET_LABEL = 'Reset';
|
||||
export const COLUMN_PICKER_EMPTY_LABEL = 'No columns available to toggle';
|
||||
|
||||
export function getHiddenColumnCount(
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface ColumnPickerProps {
|
||||
isHidden: (id: string) => boolean;
|
||||
onToggle: (id: string) => void;
|
||||
onReset?: () => void;
|
||||
showReset?: boolean;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
@@ -32,7 +33,9 @@ export function useColumnPickerState(props: ColumnPickerProps) {
|
||||
});
|
||||
|
||||
const hiddenCount = createMemo(() => getHiddenColumnCount(props.columns, props.isHidden));
|
||||
const showReset = createMemo(() => shouldShowColumnPickerReset(props.onReset, hiddenCount()));
|
||||
const showReset = createMemo(
|
||||
() => Boolean(props.showReset) || shouldShowColumnPickerReset(props.onReset, hiddenCount()),
|
||||
);
|
||||
|
||||
return {
|
||||
handleColumnToggle: (id: string) => props.onToggle(id),
|
||||
|
||||
+1
-1
@@ -257,7 +257,7 @@ describe('platform overview layout guardrails', () => {
|
||||
|
||||
it('keeps provider overview pages in the parent-table plus child-inventory stack', () => {
|
||||
for (const source of overviewSurfaceSources) {
|
||||
expect(source).toMatch(/<div[^>]*class="space-y-4"/);
|
||||
expect(source).toMatch(/<div[^>]*class="[^"]*\bspace-y-4\b[^"]*"/);
|
||||
expect(source).toContain('<PlatformSectionTabs');
|
||||
expect(source).toContain('<PlatformTableLoadingState');
|
||||
expect(source).toContain('PlatformTableEmptyState');
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
} from './proxmoxPageModel';
|
||||
import {
|
||||
getProxmoxHostColumnWidthStyle,
|
||||
getProxmoxHostTableLayoutModeForContainer,
|
||||
getProxmoxHostTableMinWidthClass,
|
||||
getProxmoxHostVisibleColumnsForLayout,
|
||||
type ProxmoxHostTableColumn,
|
||||
@@ -186,6 +187,7 @@ export const ProxmoxNodesTable: Component<{
|
||||
guests: Resource[];
|
||||
metricDisplayMode?: Accessor<WorkloadsMetricDisplayMode>;
|
||||
metricHistoryRange?: Accessor<WorkloadTableMetricHistoryRange>;
|
||||
layoutWidth?: Accessor<number | null | undefined>;
|
||||
emptyIcon: JSX.Element;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
@@ -195,7 +197,12 @@ export const ProxmoxNodesTable: Component<{
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const alertsEnabled = alertsActivation.detectionEnabled;
|
||||
const [selectedNodeId, setSelectedNodeId] = createSignal<string | null>(null);
|
||||
const layoutMode = createMemo(() => getWorkloadTableLayoutMode(breakpoint.width()));
|
||||
const layoutMode = createMemo(() => {
|
||||
const measuredWidth = props.layoutWidth?.();
|
||||
return typeof measuredWidth === 'number' && measuredWidth > 0
|
||||
? getProxmoxHostTableLayoutModeForContainer(measuredWidth)
|
||||
: getWorkloadTableLayoutMode(breakpoint.width());
|
||||
});
|
||||
const visibleColumns = createMemo(() => getProxmoxHostVisibleColumnsForLayout(layoutMode()));
|
||||
const visibleColumnIds = createMemo(() => visibleColumns().map((column) => column.id));
|
||||
const displayMode = () => props.metricDisplayMode?.() ?? 'bars';
|
||||
|
||||
@@ -29,6 +29,7 @@ import { PlatformOutdatedAgentNotice } from '@/features/platformPage/PlatformOut
|
||||
import { PlatformOutdatedSensorSetupNotice } from '@/features/platformPage/PlatformOutdatedSensorSetupNotice';
|
||||
import { collectOutdatedSensorSetupNodes } from '@/features/platformPage/sensorSetup';
|
||||
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
|
||||
import { useObservedElementWidth } from '@/hooks/useObservedElementWidth';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import {
|
||||
PlatformErrorState,
|
||||
@@ -272,10 +273,12 @@ interface ProxmoxOverviewProps {
|
||||
|
||||
function ProxmoxOverview(props: ProxmoxOverviewProps) {
|
||||
const currentModel = createMemo(() => props.model?.() ?? EMPTY_PROXMOX_PAGE_MODEL);
|
||||
const overviewWidth = useObservedElementWidth();
|
||||
const workloadsState = useWorkloadsState({
|
||||
vms: [],
|
||||
containers: [],
|
||||
nodes: [],
|
||||
layoutWidth: overviewWidth.width,
|
||||
useWorkloads: true,
|
||||
forcedPlatform: PROXMOX_PLATFORM_FILTER,
|
||||
excludedWorkloadTypes: PROXMOX_WORKLOAD_EXCLUDED_TYPES,
|
||||
@@ -309,7 +312,7 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
<div ref={overviewWidth.setElement} class="pulse-wide-data-surface space-y-4">
|
||||
<Show when={showSharedFilterToolbar()}>
|
||||
<div data-summary-clear-ignore>
|
||||
<WorkloadsFilter
|
||||
@@ -355,6 +358,7 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
|
||||
guests={currentModel().guests}
|
||||
metricDisplayMode={props.metricDisplayMode}
|
||||
metricHistoryRange={props.metricHistoryRange}
|
||||
layoutWidth={overviewWidth.width}
|
||||
emptyIcon={<ProxmoxIcon class="h-6 w-6 text-slate-400" />}
|
||||
emptyTitle="No Proxmox VE nodes"
|
||||
emptyDescription="Proxmox VE nodes appear here once a PVE host reports inventory."
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getProxmoxHostColumnWidthStyle,
|
||||
getProxmoxHostTableMinWidthClass,
|
||||
getProxmoxHostTableLayoutModeForContainer,
|
||||
getProxmoxHostVisibleColumnsForLayout,
|
||||
} from '../proxmoxHostTableModel';
|
||||
|
||||
@@ -12,30 +13,44 @@ describe('proxmoxHostTableModel', () => {
|
||||
const ids = columns.map((column) => column.id);
|
||||
|
||||
expect(ids).toEqual(['node', 'cpu', 'memory', 'disk']);
|
||||
expect(getProxmoxHostTableMinWidthClass('mobile')).toBe('min-w-full');
|
||||
expect(getProxmoxHostTableMinWidthClass('mobile')).toBe('min-w-[36rem]');
|
||||
expect(getProxmoxHostColumnWidthStyle('node', 'mobile', ids)).toEqual({ width: '40%' });
|
||||
expect(getProxmoxHostColumnWidthStyle('cpu', 'mobile', ids)).toEqual({ width: '20%' });
|
||||
expect(getProxmoxHostColumnWidthStyle('memory', 'mobile', ids)).toEqual({ width: '20%' });
|
||||
expect(getProxmoxHostColumnWidthStyle('disk', 'mobile', ids)).toEqual({ width: '20%' });
|
||||
});
|
||||
|
||||
it('adds temperature and guest counts before slower-changing metadata on tablet', () => {
|
||||
it('adds operational context before inventory metadata on tablet', () => {
|
||||
expect(getProxmoxHostVisibleColumnsForLayout('tablet').map((column) => column.id)).toEqual([
|
||||
'node',
|
||||
'cpu',
|
||||
'memory',
|
||||
'disk',
|
||||
'temp',
|
||||
'vms',
|
||||
'cts',
|
||||
'uptime',
|
||||
'cluster',
|
||||
]);
|
||||
expect(getProxmoxHostTableMinWidthClass('tablet')).toBe('min-w-full');
|
||||
expect(getProxmoxHostTableMinWidthClass('tablet')).toBe('min-w-[50rem]');
|
||||
});
|
||||
|
||||
it('keeps the full host inventory table on compact and wide layouts', () => {
|
||||
it('adds guest counts on compact and reserves version for wide layouts', () => {
|
||||
const compactIds = getProxmoxHostVisibleColumnsForLayout('compact').map((column) => column.id);
|
||||
|
||||
expect(compactIds).toEqual([
|
||||
'node',
|
||||
'cpu',
|
||||
'memory',
|
||||
'disk',
|
||||
'temp',
|
||||
'uptime',
|
||||
'vms',
|
||||
'cts',
|
||||
'cluster',
|
||||
]);
|
||||
expect(getProxmoxHostColumnWidthStyle('cluster', 'compact', compactIds)).toEqual({
|
||||
width: '10%',
|
||||
});
|
||||
expect(getProxmoxHostVisibleColumnsForLayout('wide').map((column) => column.id)).toEqual([
|
||||
'node',
|
||||
'version',
|
||||
'cpu',
|
||||
@@ -47,16 +62,22 @@ describe('proxmoxHostTableModel', () => {
|
||||
'cts',
|
||||
'cluster',
|
||||
]);
|
||||
expect(getProxmoxHostColumnWidthStyle('cluster', 'compact', compactIds)).toEqual({
|
||||
width: '10.7527%',
|
||||
});
|
||||
});
|
||||
|
||||
it('chooses host columns from available container width', () => {
|
||||
expect(getProxmoxHostTableLayoutModeForContainer(799)).toBe('mobile');
|
||||
expect(getProxmoxHostTableLayoutModeForContainer(800)).toBe('tablet');
|
||||
expect(getProxmoxHostTableLayoutModeForContainer(1039)).toBe('tablet');
|
||||
expect(getProxmoxHostTableLayoutModeForContainer(1040)).toBe('compact');
|
||||
expect(getProxmoxHostTableLayoutModeForContainer(1319)).toBe('compact');
|
||||
expect(getProxmoxHostTableLayoutModeForContainer(1320)).toBe('wide');
|
||||
});
|
||||
|
||||
it('fits the container on compact and reserves the fixed floor only on wide', () => {
|
||||
// The compact band (900-1440px) covers most laptops. Forcing a 1240px floor
|
||||
// The compact band covers most laptops. Forcing a 1240px floor
|
||||
// there pushed the rightmost column behind a horizontal scroll, so compact
|
||||
// now fits its container; only wide keeps the fixed-width floor.
|
||||
expect(getProxmoxHostTableMinWidthClass('compact')).toBe('min-w-full');
|
||||
expect(getProxmoxHostTableMinWidthClass('compact')).toBe('min-w-[64rem]');
|
||||
expect(getProxmoxHostTableMinWidthClass('wide')).toBe('min-w-[1240px]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,11 +26,11 @@ const HOST_COLUMN_MIN_LAYOUT: Record<ProxmoxHostTableColumnId, WorkloadTableLayo
|
||||
memory: 'mobile',
|
||||
disk: 'mobile',
|
||||
temp: 'tablet',
|
||||
vms: 'tablet',
|
||||
cts: 'tablet',
|
||||
version: 'compact',
|
||||
uptime: 'compact',
|
||||
cluster: 'compact',
|
||||
uptime: 'tablet',
|
||||
cluster: 'tablet',
|
||||
vms: 'compact',
|
||||
cts: 'compact',
|
||||
version: 'wide',
|
||||
};
|
||||
|
||||
// CPU, memory, and disk render the same kind of usage bar, so they share
|
||||
@@ -40,16 +40,16 @@ const HOST_COLUMN_MIN_LAYOUT: Record<ProxmoxHostTableColumnId, WorkloadTableLayo
|
||||
// columns (version pill, uptime, temp gauge, vms/cts badges, cluster
|
||||
// pill) take only what they need.
|
||||
const HOST_COLUMN_DESKTOP_WIDTHS: Record<ProxmoxHostTableColumnId, number> = {
|
||||
node: 17,
|
||||
version: 6,
|
||||
uptime: 6,
|
||||
node: 18,
|
||||
version: 7,
|
||||
uptime: 7,
|
||||
cpu: 13,
|
||||
memory: 13,
|
||||
disk: 13,
|
||||
temp: 5,
|
||||
temp: 6,
|
||||
vms: 5,
|
||||
cts: 5,
|
||||
cluster: 10,
|
||||
cluster: 13,
|
||||
};
|
||||
|
||||
const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record<
|
||||
@@ -63,15 +63,34 @@ const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record<
|
||||
disk: 20,
|
||||
},
|
||||
tablet: {
|
||||
node: 28,
|
||||
cpu: 18,
|
||||
memory: 18,
|
||||
disk: 18,
|
||||
temp: 6,
|
||||
vms: 6,
|
||||
cts: 6,
|
||||
node: 22,
|
||||
cpu: 15,
|
||||
memory: 15,
|
||||
disk: 15,
|
||||
temp: 8,
|
||||
uptime: 11,
|
||||
cluster: 14,
|
||||
},
|
||||
compact: HOST_COLUMN_DESKTOP_WIDTHS,
|
||||
compact: {
|
||||
node: 20,
|
||||
cpu: 15,
|
||||
memory: 15,
|
||||
disk: 15,
|
||||
temp: 7,
|
||||
uptime: 8,
|
||||
vms: 5,
|
||||
cts: 5,
|
||||
cluster: 10,
|
||||
},
|
||||
};
|
||||
|
||||
export const getProxmoxHostTableLayoutModeForContainer = (
|
||||
width: number,
|
||||
): WorkloadTableLayoutMode => {
|
||||
if (!Number.isFinite(width) || width < 800) return 'mobile';
|
||||
if (width < 1040) return 'tablet';
|
||||
if (width < 1320) return 'compact';
|
||||
return 'wide';
|
||||
};
|
||||
|
||||
// Column order follows the canonical recommended ordering documented in
|
||||
@@ -112,12 +131,15 @@ export const getProxmoxHostColumnWidthStyle = (
|
||||
return getPlatformTableWeightedColumnWidthStyle(columnId, weights, visibleColumnIds);
|
||||
};
|
||||
|
||||
// Only the `wide` layout (>= 1440px viewport) reserves a fixed 1240px floor so
|
||||
// the metric bars hit their canonical 140px width. The `compact` band spans
|
||||
// 900-1440px, which covers most laptops; forcing 1240px there pushed the
|
||||
// rightmost column (Cluster) behind a horizontal scroll that is easy to miss.
|
||||
// Because the table is `table-fixed` with percentage column widths, `min-w-full`
|
||||
// fits the container exactly and the bars scale down gracefully instead.
|
||||
// Each layout has an explicit readable floor. The shared platform shell
|
||||
// otherwise applies its generic 48rem minimum even to the four-column mobile
|
||||
// layout, causing needless overflow around tablet widths. Wider modes retain
|
||||
// progressively larger floors so metric bars and labels never collapse.
|
||||
export const getProxmoxHostTableMinWidthClass = (
|
||||
layoutMode: WorkloadTableLayoutMode,
|
||||
): 'min-w-full' | 'min-w-[1240px]' => (layoutMode === 'wide' ? 'min-w-[1240px]' : 'min-w-full');
|
||||
): 'min-w-[36rem]' | 'min-w-[50rem]' | 'min-w-[64rem]' | 'min-w-[1240px]' => {
|
||||
if (layoutMode === 'mobile') return 'min-w-[36rem]';
|
||||
if (layoutMode === 'tablet') return 'min-w-[50rem]';
|
||||
if (layoutMode === 'compact') return 'min-w-[64rem]';
|
||||
return 'min-w-[1240px]';
|
||||
};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { cleanup, render, waitFor } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useObservedElementWidth } from '../useObservedElementWidth';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('useObservedElementWidth', () => {
|
||||
it('tracks the rendered container and disconnects its observer', async () => {
|
||||
let measuredWidth = 640;
|
||||
let resizeCallback: ResizeObserverCallback | undefined;
|
||||
const disconnect = vi.fn();
|
||||
|
||||
class ResizeObserverStub {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback;
|
||||
}
|
||||
observe = vi.fn();
|
||||
disconnect = () => disconnect();
|
||||
unobserve = vi.fn();
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub);
|
||||
|
||||
let width = () => null as number | null;
|
||||
const Harness = () => {
|
||||
const observed = useObservedElementWidth();
|
||||
width = observed.width;
|
||||
return (
|
||||
<div
|
||||
ref={(element) => {
|
||||
Object.defineProperty(element, 'clientWidth', {
|
||||
configurable: true,
|
||||
get: () => measuredWidth,
|
||||
});
|
||||
observed.setElement(element);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const { unmount } = render(() => <Harness />);
|
||||
await waitFor(() => expect(width()).toBe(640));
|
||||
|
||||
measuredWidth = 880;
|
||||
resizeCallback?.([], {} as ResizeObserver);
|
||||
await waitFor(() => expect(width()).toBe(880));
|
||||
|
||||
unmount();
|
||||
expect(disconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createEffect, createSignal, onCleanup, type Accessor } from 'solid-js';
|
||||
|
||||
const normalizeElementWidth = (width: number): number | null =>
|
||||
Number.isFinite(width) && width > 0 ? Math.round(width) : null;
|
||||
|
||||
export interface ObservedElementWidth {
|
||||
setElement: (element: HTMLElement) => void;
|
||||
width: Accessor<number | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the usable inline width of a rendered surface. Responsive tables use
|
||||
* this instead of the viewport so side panels and constrained layouts select
|
||||
* columns that actually fit their container.
|
||||
*/
|
||||
export const useObservedElementWidth = (): ObservedElementWidth => {
|
||||
const [element, setElement] = createSignal<HTMLElement>();
|
||||
const [width, setWidth] = createSignal<number | null>(null);
|
||||
|
||||
createEffect(() => {
|
||||
const target = element();
|
||||
if (!target) return;
|
||||
|
||||
const update = () => setWidth(normalizeElementWidth(target.clientWidth));
|
||||
update();
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(target);
|
||||
onCleanup(() => observer.disconnect());
|
||||
});
|
||||
|
||||
return { setElement, width };
|
||||
};
|
||||
@@ -214,6 +214,12 @@
|
||||
--pulse-shell-padding-inline: clamp(1rem, 2vw, 3rem);
|
||||
}
|
||||
|
||||
/* Dense overview tables benefit from a larger workspace on wide monitors,
|
||||
while ordinary reading and form surfaces keep the default 1560px cap. */
|
||||
.pulse-shell:has(.pulse-wide-data-surface):not(.pulse-shell--full-width) {
|
||||
--pulse-shell-max-width: min(97vw, 1920px);
|
||||
}
|
||||
|
||||
.pulse-panel {
|
||||
padding: var(--pulse-panel-padding);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user