Clarify charts toggle affordance

This commit is contained in:
rcourtman 2026-04-30 10:03:15 +01:00
parent 0bbba3b818
commit f5d90f0fe5
13 changed files with 125 additions and 83 deletions

View file

@ -341,7 +341,12 @@ work extends shared components instead of creating new local variants.
of staying as loose filter-row children, and those controls must stay
grouped when dense toolbars wrap so popovers remain viewport-safe instead of
drifting off-screen from page-local flex behavior. Shared `FilterToolbarPanel` owns
default filter-popover geometry, while narrow consumers such as
default filter-popover geometry, and `FilterToolbar` owns the shared chart
visibility display action: Workloads, Storage, Infrastructure, and future
summary-bearing pages must use `ChartVisibilityToggleButton` so the
affordance exposes one `Show charts` / `Hide charts` pressed-state contract
instead of rebuilding a one-option segmented control or an in-summary
collapse chevron page by page. Narrow consumers such as
`ColumnPicker` must opt into their panel width through that primitive rather
than layering competing width classes page by page.
4. Add guardrail tests when a new shared pattern is introduced.

View file

@ -829,7 +829,10 @@ that clips trailing actions. The workload shell must route table display
actions such as grouped/list mode and chart visibility through
`PageControls.toolbarTrailing`, leaving route/filter selects as primary toolbar
children so the display-action cluster wraps together with Columns/Reset across
narrow desktop widths.
narrow desktop widths. Workload chart visibility is a display preference, not
an in-summary collapse affordance: the toolbar action must expose explicit
`Show charts` / `Hide charts` pressed state, and hiding charts must remove the
summary section rather than leaving an empty collapsed summary band on screen.
The Workloads-owned filter-config assembly now lives in
`frontend-modern/src/components/Workloads/useWorkloadsState.ts`, so future
filter runtime changes must extend through those owners instead of

View file

@ -102,7 +102,10 @@ state.
by the Storage page model and exposed through the shared `PageControls`
trailing action rail. Storage must keep the charts toggle, column picker,
and reset affordance on that shared rail so the Workloads and Storage
filter sections collapse through the same primitive contract.
filter sections collapse through the same primitive contract. The charts
toggle must read as an explicit `Show charts` / `Hide charts` pressed
display action, and the off state must remove the summary section fully
instead of leaving a collapsed summary shell in the interface.
Ceph table shells on the storage route share the same frontend-primitives
table contract: `frontend-modern/src/pages/Ceph.tsx` may own Ceph-specific
columns and rows, but horizontal overflow and scrollbar hiding must route

View file

@ -365,6 +365,11 @@ AI-only summary payloads, or page-local heuristics.
the row may highlight in place through the shared active-resource id; if it
is off-screen, the page must offer an explicit `Jump to row` affordance
rather than auto-scrolling or collapsing the table on hover.
12a. Keep infrastructure summary visibility as a page-display preference, not a
unified-resource filter. `frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx`
may hide or restore the summary section through the shared
`ChartVisibilityToggleButton`, but that control must not mutate resource
identity, table membership, route-backed focus, or summary-hover scope.
13. Keep infrastructure cluster headers as canonical summary scope. Grouped
headers in `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx`
must publish cluster scope from the same `ResourceGroup` / unified-resource

View file

@ -1,6 +1,7 @@
import { Component, For, Show, type JSX } from 'solid-js';
import { Card } from '@/components/shared/Card';
import {
ChartVisibilityToggleButton,
FilterDivider,
FilterSegmentedControl,
LabeledFilterSelect,
@ -115,31 +116,9 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
});
const chartsToolbarAction = () =>
props.onChartsToggle ? (
<FilterSegmentedControl
class="hidden lg:inline-flex"
value={props.chartsCollapsed?.() ? 'hidden' : 'shown'}
onChange={() => props.onChartsToggle?.()}
aria-label="Charts"
options={[
{
value: 'shown',
title: props.chartsCollapsed?.() ? 'Show charts' : 'Hide charts',
label: (
<>
<svg
class="w-3 h-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
Charts
</>
),
},
]}
<ChartVisibilityToggleButton
collapsed={props.chartsCollapsed?.() ?? false}
onToggle={() => props.onChartsToggle?.()}
/>
) : undefined;

View file

@ -151,8 +151,11 @@ describe('StorageControls', () => {
/>
));
const chartsButton = screen.getByRole('button', { name: /charts/i });
const chartsButton = screen.getByRole('button', { name: 'Hide charts' });
expect(chartsButton.closest('.page-controls-toolbar-actions')).not.toBeNull();
expect(chartsButton).toHaveTextContent('Charts');
expect(chartsButton).toHaveAttribute('aria-pressed', 'true');
expect(chartsButton).toHaveAttribute('title', 'Hide charts');
fireEvent.click(chartsButton);

View file

@ -1,6 +1,9 @@
import { Component, For, Show } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { FilterSegmentedControl, LabeledFilterSelect } from '@/components/shared/FilterToolbar';
import {
ChartVisibilityToggleButton,
LabeledFilterSelect,
} from '@/components/shared/FilterToolbar';
import { GroupedTableModeSegmentedControl } from '@/components/shared/GroupedTableModeSegmentedControl';
import { PageControls } from '@/components/shared/PageControls';
import { SearchInput } from '@/components/shared/SearchInput';
@ -72,31 +75,9 @@ export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
/>
<Show when={props.onChartsToggle}>
<FilterSegmentedControl
class="hidden lg:inline-flex"
value={props.chartsCollapsed?.() ? 'hidden' : 'shown'}
onChange={() => props.onChartsToggle?.()}
aria-label="Charts"
options={[
{
value: 'shown',
title: props.chartsCollapsed?.() ? 'Show charts' : 'Hide charts',
label: (
<>
<svg
class="w-3 h-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
Charts
</>
),
},
]}
<ChartVisibilityToggleButton
collapsed={props.chartsCollapsed?.() ?? false}
onToggle={() => props.onChartsToggle?.()}
/>
</Show>
</>

View file

@ -557,7 +557,23 @@ describe('WorkloadsFilter', () => {
onChartsToggle: vi.fn(),
});
render(() => <WorkloadsFilter {...props} />);
expect(screen.getByText('Charts')).toBeInTheDocument();
const chartsButton = screen.getByRole('button', { name: 'Hide charts' });
expect(chartsButton).toBeInTheDocument();
expect(chartsButton).toHaveTextContent('Charts');
expect(chartsButton).toHaveAttribute('aria-pressed', 'true');
expect(chartsButton).toHaveAttribute('title', 'Hide charts');
});
it('labels the Charts button as a show action when charts are collapsed', () => {
const props = makeProps({
chartsCollapsed: vi.fn(() => true),
onChartsToggle: vi.fn(),
});
render(() => <WorkloadsFilter {...props} />);
const chartsButton = screen.getByRole('button', { name: 'Show charts' });
expect(chartsButton).toHaveTextContent('Charts');
expect(chartsButton).toHaveAttribute('aria-pressed', 'false');
expect(chartsButton).toHaveAttribute('title', 'Show charts');
});
it('does not render Charts button when onChartsToggle is not provided', () => {
@ -573,7 +589,7 @@ describe('WorkloadsFilter', () => {
onChartsToggle,
});
render(() => <WorkloadsFilter {...props} />);
fireEvent.click(screen.getByText('Charts'));
fireEvent.click(screen.getByRole('button', { name: 'Hide charts' }));
expect(onChartsToggle).toHaveBeenCalled();
});
});

View file

@ -2,6 +2,7 @@ import { cleanup, render, screen, waitFor } from '@solidjs/testing-library';
import { For, createSignal } from 'solid-js';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
ChartVisibilityToggleButton,
FilterHeader,
FilterToolbarPanel,
FilterSegmentedControl,
@ -49,6 +50,34 @@ describe('FilterHeader', () => {
expect(onChange).toHaveBeenCalledWith('warnings');
});
it('keeps chart visibility as an explicit display toggle action', () => {
const [collapsed, setCollapsed] = createSignal(false);
const onToggle = vi.fn(() => setCollapsed((current) => !current));
render(() => (
<ChartVisibilityToggleButton
collapsed={collapsed()}
onToggle={onToggle}
data-testid="charts-toggle"
/>
));
const hideButton = screen.getByRole('button', { name: 'Hide charts' });
expect(hideButton).toHaveTextContent('Charts');
expect(hideButton).toHaveAttribute('aria-pressed', 'true');
expect(hideButton).toHaveAttribute('title', 'Hide charts');
expect(hideButton).toHaveClass('hidden');
expect(hideButton).toHaveClass('lg:inline-flex');
hideButton.click();
const showButton = screen.getByRole('button', { name: 'Show charts' });
expect(onToggle).toHaveBeenCalledTimes(1);
expect(showButton).toHaveTextContent('Charts');
expect(showButton).toHaveAttribute('aria-pressed', 'false');
expect(showButton).toHaveAttribute('title', 'Show charts');
});
it('keeps shared toggle behavior on shell, runtime, and model owners', () => {
expect(toggleSource).toContain('useToggleState');
expect(toggleSource).toContain('getToggleTrackClass');

View file

@ -1,4 +1,5 @@
import { Component, For, JSX, Show, createEffect, onCleanup, splitProps } from 'solid-js';
import BarChartIcon from 'lucide-solid/icons/bar-chart';
import ListFilterIcon from 'lucide-solid/icons/list-filter';
import { segmentedButtonClass } from '@/utils/segmentedButton';
@ -330,6 +331,33 @@ export const FilterActionButton: Component<FilterActionButtonProps> = (props) =>
);
};
interface ChartVisibilityToggleButtonProps extends Omit<
JSX.ButtonHTMLAttributes<HTMLButtonElement>,
'aria-label' | 'aria-pressed' | 'children' | 'onClick' | 'title'
> {
collapsed: boolean;
onToggle: () => void;
}
export const ChartVisibilityToggleButton: Component<ChartVisibilityToggleButtonProps> = (props) => {
const [local, buttonProps] = splitProps(props, ['collapsed', 'onToggle', 'class']);
const label = () => (local.collapsed ? 'Show charts' : 'Hide charts');
return (
<FilterActionButton
{...buttonProps}
class={`hidden lg:inline-flex ${local.class ?? ''}`.trim()}
active={!local.collapsed}
aria-label={label()}
aria-pressed={!local.collapsed}
title={label()}
onClick={() => local.onToggle()}
>
<BarChartIcon class="h-3 w-3" />
Charts
</FilterActionButton>
);
};
interface FilterToolbarPanelProps extends JSX.HTMLAttributes<HTMLDivElement> {
children: JSX.Element;
widthClass?: string;

View file

@ -120,6 +120,12 @@ describe('page controls guardrails', () => {
it('keeps display controls and utility actions on the shared toolbar rail', () => {
expect(workloadsFilterSource).toContain('toolbarTrailing={');
expect(storageFilterSource).toContain('toolbarTrailing={');
expect(workloadsFilterSource).toContain('ChartVisibilityToggleButton');
expect(storageFilterSource).toContain('ChartVisibilityToggleButton');
expect(infrastructurePageSurfaceSource).toContain('ChartVisibilityToggleButton');
expect(workloadsFilterSource).not.toContain('aria-label="Charts"');
expect(storageFilterSource).not.toContain('aria-label="Charts"');
expect(infrastructurePageSurfaceSource).not.toContain('aria-label="Charts"');
expect(recoveryHistorySectionSource).not.toContain('toolbarClass="lg:flex-nowrap"');
expect(recoveryHistorySectionSource).not.toContain('ml-auto flex items-center gap-2');
});

View file

@ -3,7 +3,10 @@ import { useNavigate } from '@solidjs/router';
import { buildInfrastructureOnboardingPath } from '@/components/Settings/infrastructureWorkspaceModel';
import { EmptyState } from '@/components/shared/EmptyState';
import { Card } from '@/components/shared/Card';
import { FilterSegmentedControl, LabeledFilterSelect } from '@/components/shared/FilterToolbar';
import {
ChartVisibilityToggleButton,
LabeledFilterSelect,
} from '@/components/shared/FilterToolbar';
import { GroupedTableModeSegmentedControl } from '@/components/shared/GroupedTableModeSegmentedControl';
import { PageControls } from '@/components/shared/PageControls';
import { PageHeader } from '@/components/shared/PageHeader';
@ -235,31 +238,9 @@ export function InfrastructurePageSurface() {
onChange={(value) => setGroupingMode(value as GroupingMode)}
/>
<FilterSegmentedControl
class="hidden lg:inline-flex"
value={summaryCollapsed() ? 'hidden' : 'shown'}
onChange={() => setSummaryCollapsed((c) => !c)}
aria-label="Charts"
options={[
{
value: 'shown',
title: summaryCollapsed() ? 'Show charts' : 'Hide charts',
label: (
<>
<svg
class="w-3 h-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
Charts
</>
),
},
]}
<ChartVisibilityToggleButton
collapsed={summaryCollapsed()}
onToggle={() => setSummaryCollapsed((collapsed) => !collapsed)}
/>
</PageControls>
</Card>

View file

@ -82,6 +82,9 @@ describe('InfrastructurePageSurface guardrails', () => {
);
expect(infrastructurePageSurfaceSource).toContain('data-summary-clear-ignore');
expect(infrastructurePageSurfaceSource).toContain('GroupedTableModeSegmentedControl');
expect(infrastructurePageSurfaceSource).toContain('ChartVisibilityToggleButton');
expect(infrastructurePageSurfaceSource).not.toContain('aria-label="Charts"');
expect(infrastructurePageSurfaceSource).not.toContain('FilterSegmentedControl');
expect(infrastructurePageSurfaceSource).not.toContain('aria-label="Group by"');
expect(infrastructurePageSurfaceSource).not.toContain('aria-label="Group By"');
expect(infrastructurePageSurfaceSource).not.toContain("title: 'Grouped table view'");